Skip to content

The conventions round, and the audit that followed it - #55

Draft
Harted wants to merge 72 commits into
mainfrom
chore/audit
Draft

The conventions round, and the audit that followed it#55
Harted wants to merge 72 commits into
mainfrom
chore/audit

Conversation

@Harted

@Harted Harted commented Sep 4, 2026

Copy link
Copy Markdown
Member

Summary

Two rounds on one branch. The first wrote down the conventions this codebase had
already settled and made a test assert each one, so breaking a rule fails
yarn test instead of waiting for a reviewer. The second audited the app by
area, and this branch carries a fix for everything that came back blocking.

The audit reported 28 blocking findings, and grouping them by cause left 21.
All 21 are built here, one commit each, every one carrying the regression test
that was red before the fix.

Merge this with a rebase, not a squash. There are no fixup commits to hide:
each of the 70 is one concern with its own message and its own test. A squash
puts all of it behind one subject line and takes the reasoning with it.

70 commits, 226 files changed, 12736 insertions(+), 3427 deletions(-)

What a user gets

Full list in CHANGELOG.md under Unreleased. By subject:

The server answers only for what it hosts. On a shared RS-485 line it used
to reply for every address on the bus, including the real devices on it. It now
says nothing for an address it does not host, and a write to a unit you never
configured no longer creates one. Unit 0 is the broadcast address on RTU and
behaves like one.

Writing from the client does what the dialog shows. Writing one coil no
longer switches off the ones beside it, an empty value field no longer goes out
as a zero, and the dialog no longer keeps the data type of the address you
opened before.

A configuration comes back the way you left it. Loading one no longer
rewrites your own words in names and comments, a register at an address past
65535 is refused and named instead of loaded, a config that does not parse now
loses only the field that failed, and the parity list offers only what the
serial binding accepts on every platform.

Connections survive the things that used to drop them. Opening the server in
its own window no longer restarts every running server, a server that fails to
bind says so instead of looking up, unplugging the serial adapter stops the RTU
server in the view, a hung disconnect no longer costs you auto-reconnect, and on
macOS opening Modbux again while it sits in the Dock brings the window back
rather than a main-process error dialog.

What is asserted now

src/__tests__/conformance.test.ts asserts the conventions in
CONTRIBUTING.md under Code style: one store selector per field, an action
fetched where it runs, every component wrapped in meme, a local store named
after its component, MUI imported deep, nothing in src/shared reaching into
src/main, every interactive element carrying a data-testid, every channel
that carries an object declaring a schema, every channel having a caller in the
renderer, every configured path alias imported through, and every configured
include pointing at something. Each rule asserts its population is not empty
before it asserts the population holds no violation, because a meter that reads
no files passes every rule it has.

noUncheckedIndexedAccess is on, and ! is a lint error. Where an index was
provably in range the index went away rather than gaining an assertion.

What this does not do

The audit also reported 74 annoying and 44 cosmetic findings. None of them are
triaged and none are in here. Three annoying ones were picked up in passing
because they sat in code a blocking fix was already touching.

The audit documents live in tmp/, which is gitignored, so they are on my
machine rather than in the repository.

How to test

yarn verify. Every one of the 21 causes carries a test in its own commit, and
e2e/specs/02-standalone/02-macos-dock.spec.ts is new: it holds a Modbus master
on Modbux's own server and counts the reads a device is sent while every window
is closed, which is how the macOS Dock behaviour is measured rather than assumed.

Locally on macOS: lint 0, typecheck 0, yarn test 934 passed, yarn test:e2e
581 passed.

CI on this pull request runs lint, typecheck and the unit tests, on Linux.
e2e.yml is workflow_dispatch only, so the Playwright suites on Windows and
macOS need a run started by hand before this merges.

Three write channels validate their payload before the handler runs: write,
add_replace_server_register and set_bool. A payload that fails comes back as
a message and a console entry, and the handler is never called.

The schemas were missing rather than unwired. Twelve of the thirteen IPC
argument types were hand-written interfaces, so WriteParameters,
AddRegisterParams and SetBooleanParameters are now inferred from Zod like
everything else that crosses a process boundary.

A guarded handler is given the parsed payload, so unknown keys are stripped
before anything acts on them. Nothing throws across the boundary, because an
error there surfaces in the renderer as an unhandled rejection carrying the
channel name and nothing else.

A schema is only accepted on a channel that returns void. create_server
answers with the port it actually bound and the renderer writes that into the
port field, so those validate in their own handler instead.

A write of an unparseable value used to send NaN to modbus-serial and now
stops with a message.
ValueGenerators described its maps with the ValueGenerator class itself, which
meant shared imported from main. shared is imported by all three processes and
is the one layer that may not do that.

Only dispose is ever called on a generator from outside, so the interface says
exactly that and ValueGenerator implements it. Type-only either way, so no
bundle changes.
Two channels were being called from a store and a component for the same
reason, which put two writers on one piece of state.

setRegisterMapping had a debounced sync in the store and an undebounced one in
RegisterConfig, there because turning on read configuration reads immediately
and cannot wait 150 ms. The store now exposes that flush, so the component
asks for it instead of sending the mapping itself.

getAppVersion was read into the store at startup and then asked for again by
both save paths. They read the store, which already holds it. UpdateBanner
keeps its own call on purpose: it is tested on its own, and coupling it to the
root store to save one read of a value that cannot change is a bad trade.

The other two the audit flagged are not duplicates. In the store, read is a
consequence of flipping endianness and stopScanningUnitIds is a reload
cleanup; in the components, both are the user pressing a button. Same channel,
different concerns.
ServerRtuConfig, AddRegister, ServerBitMapDetail, BitIndicator and
BitSettingsPopover sat next to a parent instead of in a folder of their own.
AddRegister takes its store, its helpers and their test along, so everything
that belongs to it is in one place.

Nothing but import paths changed: eight renames, eight lines, and no content
edit in any moved file. The wrapping in meme comes next and would be
unreviewable on top of a move this wide.
At 900 lines and 27 components it was more than twice the next file in the
renderer on both counts, and the only file the size rule actually catches.

The split is by subject, not one file per component:

  AddRegister.tsx          the dialog and its layout
  registerFields.tsx       where the register lives and what it is called
  valueParameters.tsx      what value it produces, fixed or generated
  addRegisterActions.tsx   the buttons and the submit they share
  maskedInputs.tsx         the six IMask wrappers

The masked inputs sit together because they are six near-identical pairs of a
forwardRef wrapper and its memo. Whatever gets done to one of them should be
done to all six, which is easier to see in one file than spread through the
dialog. Whether they want to be one parameterised input, next to the ones
shared/inputs already has, is a separate question.

Every declaration moved unchanged; only the import blocks are new.
It was 89 lines in a component file doing two unrelated things, and the only
function declaration in the renderer, which is a fair hint it never landed
anywhere on purpose.

The pure half is a translation: what the dialog holds, all of it strings, into
the params the server stores. That is toRegisterParams in the helpers, next to
getRegisterSize and isAddressInUse, with eight tests over the conversions that
were carrying real rules and no coverage. An interval is typed in seconds and
stored in milliseconds. A unix timestamp is stored in seconds while a datetime
picked in the same field is stored in milliseconds. A utf8 register falls back
to ten registers. A generated timestamp reads the clock, so its min and max are
pinned to zero.

The other half writes through the server store, which makes it a state
mutation, so it is now a submit action on the form store rather than something
a button does for itself.

While there: the next-address button was calculating a register size inline
with the same four branches getRegisterSize already holds.
Two save handlers stayed async after reading the version off the store took
their only await away. Both hang on an onClick, which discards what they
return, so nothing else had to change.

The split left its section banners behind. They sit above the component they
introduce, and the splitter cut on the const line, so each banner stayed with
the component before it. Min Max components ended up over the interval mask,
Fixed Or Generator over the comment field, and three named things that had
moved to another file or stopped existing: MAIN, Comment, Shared submit logic,
and a note about an endianness button that was removed a while ago.

Every banner now introduces something that is actually under it.
Ported from ~/rust/ploxc, which has ten skills and a CLAUDE.md that fits on a
screen. Most of that is about Rust and about a compiler's own documents; this is
the half that is not.

CLAUDE.md holds the writing rule, the four invariants a session breaks when it
does not know them, and what the two test loops cost. It imports CONTRIBUTING.md
rather than repeating it.

/prose is the rule with its trigger table: a sentence shape on the left, the
command that settles it on the right. Its gate is not "did you measure this" but
"which set did the command count, and is that the noun in the sentence" — the
failures it is built from all passed the first question.

Its two reference files carry this branch's own failures rather than the ones it
was ported with. A component census that counted `^const` declarations under a
sentence about components. A step sized as wiring when twelve of thirteen
argument types had no schema to wire. Six section banners that stopped naming
what was under them after a split, none of which failed anything.

The measurement in CLAUDE.md was itself written from memory first, at fifteen
seconds, and the loop takes seventy-five.
A skill runs only when someone remembers it, and the moment prose needs checking
is a write, which no description can name. So the trigger is the write itself: a
markdown file, or an edit that adds a comment.

It is a reminder and never a block. The first firing of a session states the
rule and every one after asks two questions, because a hook that argues on every
edit becomes wallpaper.

The hooks live under .claude/ rather than in tools/, which holds scripts that
belong to the app. Nothing under .claude/ is part of the build: no package.json
entry, no postinstall, no CI. They are .mjs on node for the same reason — the
least important thing here should not be what decides that this project has a
second runtime.

Both directions are pinned in the suite. The negative cases matter as much as
the positive ones: a matcher narrowed to kill a false positive is how the false
negatives get made. The payloads are built rather than typed, because a
hand-escaped one in a shell went through unparseable and the hook read that as
nothing to say, which looks exactly like a matcher declining.
The routine this branch has been running by hand: read the diff, lint,
typecheck, the unit suite, the e2e specs the change reaches, report, then
commit. It was in nobody's file.

Ordered by what gets skipped rather than by when it happens, so reading the
diff comes before running anything and the prose pass is a step rather than an
afterthought. The e2e rule is split in two on purpose: the specs a change
touches while you work, the suite once at the end of a branch. Running it per
commit is how a branch stops being worked on.

Two references carry this branch's own incidents. A build artefact that rode in
on `git add -A` and was caught a commit later while reading a file list for
another reason. Six section banners a split left naming the wrong thing, where
lint, typecheck, 591 unit tests and 86 e2e specs were all green — a banner is
prose, and prose has no suite.

The claim that `yarn test` does not typecheck is pasted as what the two
commands print on the same file rather than asserted.
Which tests a change needs, and the proof each one can fail. Two rules carry it:
every fix ships a pair, and a test written after the fix proves nothing until
you have watched it go red.

Its own rule was applied to work from earlier on this branch. The eight tests
over toRegisterParams were written against code that had just been extracted,
and passed on the first run — which proves only that they agree with what they
were copied from. Mutating each rule separately turned exactly one test red per
mutation, which is what makes the set discriminating rather than overlapping.

The e2e section is about this suite rather than about testing. One app, one
worker, no retries, every spec serial and cleaning up before it starts rather
than after it ends. The number in the filename is the order, and a spec that
needs what an earlier one built breaks when run alone.

The table of what each suite can see is there because the fast loop cannot
protect a behaviour only Playwright reaches, and that is worth saying while the
test is being written rather than when it breaks.
A working list for whatever is in flight. Modbux ships, so a todo here is tied
to the branch being worked rather than to a roadmap, and anything that should
outlive the branch belongs in a GitHub issue or the changelog.

Not tracked, for the same reason tmp/ is not.
Two gaps, both found by using it.

The reminder degraded to a one-line question after its first firing. That short
form is the one that gets read past, and the sentence being written is what is
at stake, so it states the whole rule every time now. The session marker it
needed for that is no longer imported here.

And it never saw a commit message. Those are written with `git commit -F -`
through Bash, and the matcher was `Write|Edit`, so five commit messages on this
branch went past it while precommit says every commit hands over unconditionally.
Bash is on the matcher now.

The commit match is anchored to the start of a command or to a separator rather
than found anywhere in the string. Matching anywhere fired on the very run that
added it: the command was a heredoc writing a test *about* `git commit`. That
shape is now one of the cases.
Four destinations, and the first step is not one of them: is the fix small
enough that it belongs in this commit instead. Writing a defect down converts it
into a backlog item, and backlog items feel handled.

The routing differs from the repo it came from because the destinations do.
There is no docs/decisions/ here, so a reason goes to the memory directory,
which is where every decision this branch made already lives. TODO.md is
untracked, so anything another person needs is a GitHub issue rather than a note
in it.

The tense is the test. "Enable require-await" is a task; the paragraph
explaining how two handlers came to be async without an await is a record. If
the sentence explains, it is not a task.

Its reference carries the gauge that caught the same drift in ploxc, with both
figures attributed rather than restated as measurements: a fact from outside
this repository cites its source or it goes.
The checkpoint settled one rule: every component, props or not. A meter that
accepts a declaration rendered as JSX somewhere or exported as its file default
reads 104 wrapped and 86 bare, identically on main and on this branch, and a
half-applied rule is what produced that split. The 86 are wrapped here, in one
commit, because doing it in several is how the split happens again.

The comparator is untouched.

The four forwardRef inputs take the shape the rest of the codebase already uses:
a named Forward component carrying the displayName, wrapped by meme. Assigning
displayName to the memo instead would have left the inner component anonymous.

One test changed. SerialGroupModal's store stub was a plain object read during
render, so the test for a newly plugged adapter drove it by re-rendering the
parent with identical props, which memo refuses. The stub is a real zustand
store now, and the change reaches the component the way it does in the app.
Dropping ports from that effect's dependency list turns that one test red and
no other.
The checkpoint settled the filename: <name>.zustand.ts, matching the global
stores in context/, and named after the component rather than the folder.
Three files were still called _zustand.ts. They are serialGroupModal,
privilegedPortModal and scanUnitIds now, and their seven importers say so.

WriteModal carried its store inline, which made it the exception the rule would
have had to remember. It comes out to writeModal.zustand.ts, and the
eslint-disable header goes with it: it was there for the store's setters, and
WriteModal.tsx lints clean without it.

The typo in ValueInputZusand moves unchanged. Renaming it is step 8's, and
doing it here would hide a rename inside a move.
The checkpoint settled deep imports. It counted 42 sites, which was
@mui/material alone; the sweep also takes @mui/icons-material, @mui/x-data-grid
and @mui/x-date-pickers, because the rule is about barrels and those are
barrels. 80 barrel imports across 57 files.

Every target was chosen by compiling it rather than by the shape of the name,
and three of them would have gone wrong the other way: GridRowProps lives in
components, not models, and DateTimePicker and LocalizationProvider are named
exports rather than defaults.

Five sites keep the barrel. The exports map in @mui/x-data-grid/package.json
declares thirteen subpaths besides the root, and useGridApiContext and
useGridApiRef are exported by none of them, so the root is the only place they
can be had.
Eight frozen typos: RootZusand, ValueInputZusand, typestampColumn, Connnection,
Remeber, mobusServer.ts, lillteBigEndian.md, and the one a user reads, "Single
register only supported fot 16 bit values". Six interfaces called Props are
named after the component they belong to. Ten interactive elements carry a
data-testid that did not.

@main, @preload and @backend are gone from the vite config. All three resolved
nothing: no file imports through any of them. src/backend was real between
ec4898c and 3dea261, and the alias outlived the directory by more than a year.

#2A2A2A was not a colour someone picked. The grid computes its own row
background in dark mode as color-mix(in srgb, #1F1F1F 95%, #fff), which is
0.95 * 0x1F + 0.05 * 0xFF = 42.2, so #2A2A2A, and the two panels behind the
server lists were a hand-copy of that. The value is named once as gridSurface
and pinned into palette.DataGrid.bg, so the grid and the panels move together.
x-data-grid augments PaletteOptions but not Palette, which is why the panels
read the export rather than the theme.

Both #cccccc sites read palette.info.main now, and the Ploxc logo's other fill
reads palette.primary.main beside it.

Found while measuring: the DateTimePicker already had add-reg-datetime-input,
nested in slotProps where a meter looking at JSX attributes does not see it.
Anything counting data-testid has to look there too.
32 sites, all of them in src. It reads a flag beside scanningRegisters and is
set by the same scan that stopScanningUnitIds ends, so the plural is what the
neighbours already say.

No migration. partialize in root.zustand.ts persists name, connectionConfig,
registerConfig and registerMapping, and clientState is not among them, so the
old key was never written to storage and no saved config carries it.
Seven rules the run settled, each one a test: every component wrapped in meme,
nothing in shared importing from main, no selector returning an object and no
useShallow, every store file named <name>.zustand.ts, MUI imported deep, a
data-testid on every interactive element, and every configured alias imported
through.

Each rule asserts twice. The second assertion is that the population holds no
violation; the first is that the population is not empty, because a meter that
reads no files passes every rule it has.

It found two things the manual sweeps missed, before it was committed.
ScanRegisters.tsx held a seventh local store inline, which is exactly what
WriteModal.tsx was taken apart for one commit earlier. And vitest.config.mts
still declared @main, @preload and @backend after they left the vite config.
Both are fixed here, which is why this commit is green.

Every rule was mutated once and watched go red: the meme wrapper removed from
HomeButton, an @main import added to shared/default.ts, a selector rewritten to
return an object, useShallow imported, a deep MUI import put back on the barrel,
a data-testid deleted, a store file renamed off .zustand.ts, and a @ghost alias
added to vitest.config.mts. Each turned exactly one test red and left the other
fourteen green.

The data-testid rule reads the whole attribute text rather than the top-level
JSX attributes, because a picker carries the attribute inside slotProps and a
meter that looked only at the top level reported the DateTimePicker as missing
one it has had all along.
Seven rules were enforceable only by pointing at existing code. They are under
Code style now, each one saying what it is and why, next to the test that fails
when it is broken.

An eighth is there under its own heading, because no test can see it: the store
owns IPC that changes state and a component owns IPC the user asked for. The
same channel can be called from both and be right both times, which is what
makes it a reviewer's judgement rather than an assertion.

Two corrections. The path alias line listed @main, @preload and @backend, none
of which exist; it is @renderer/* and @shared, and there are no others. And the
data-testid bullet said every interactive element without saying which elements
are not one, so a ToggleButtonGroup and a Select's options kept turning up as
findings when they are addressed through their children.
The other fourteen. Three write paths were already guarded; the rest of the
seventeen channels taking an object or a union now declare a schema beside
their handler, so a payload that does not parse never reaches the socket. The
count is a test: a channel added without one fails yarn test.

Ten types had no schema: nine hand-written interfaces and a string union. The
leaf schemas were already sitting beside them, so the new ones are assembly
rather than invention. Two ranges the
protocol fixes are named once in shared/types/ranges.ts and read from there: a
register address is 16 bit, a unit id is one byte. A TCP port is 16 bit too and
means something else, so it has its own name.

The constraint on where a guard may go changed, because the old one was drawn
in the wrong place. It read "only a channel returning void", on the grounds
that a rejected payload leaves nothing to return. What actually matters is
whether undefined is an honest answer, so it reads that now, and the three
value-returning channels say so in their own types.

create_server and set_server_port answer Promise<number | undefined>. That is
not cosmetic: the renderer wrote String(actualPort) straight into the port
field, and String(undefined) is valid TypeScript, so the type alone would not
have caught it. All three call sites check before they write.

Two tests per channel, through initIpc rather than around it. The valid payload
must reach the listener, which a swapped schema breaks. The invalid one must
come back as a message naming the channel, which a missing schema breaks: a
channel with no guard accepts everything, so passing it a valid payload proves
nothing.
A review of the two step 9 commits found seven claims that did not hold. Three
were the suite falling short of what the doc promised, and those are fixed in
the suite rather than walked back in the prose.

The suite accepted React's bare memo as satisfying the meme rule, so a component
could take the shallow comparator the rule exists to rule out and stay green.
Only meme counts now. It also asserted nothing about whole-store subscriptions
while the doc said the renderer has none: a call with no selector and one
returning (z) => z are neither an object literal, so both walked past the check
that was there. And CONTRIBUTING.md said every configured path points at
something while nothing read the tsconfigs, where tsconfig.node.json still
included src/backend, deleted in 3dea261 along with the alias step 8 removed.

Reading the directory part off a glob got electron.vite.config.* wrong twice,
once in each direction, so the include check expands the glob instead.

Four were the doc overreaching. The folder-per-component rule was under the
heading that says these are asserted, and it is neither asserted nor true:
seventeen components sit flat, and columns/ holds a WriteModal folder. It moves
down beside the IPC rule, as the judgement it is, saying what it actually
distinguishes. The data-testid paragraph read as though a Select needed no
attribute when the suite requires one on it and exempts only its options. Both
files said seven rules when the eighth landed in 24285f8 and a ninth here. And
"each rule asserts twice" was wrong for the selector rule, which had three and
now has four. The sentence says what is actually true of all nine: the
population is checked before the violations are.
version and setVersion are the running app's own version, read once at startup
from get_app_version and written into a save file's metadata. Nothing about
them is a client. They sit on the layout store now, which is the other app-wide
one and which persists nothing, so the move touches no storage.

Its three readers say so: the two save paths and the version in the corner of
the home screen.
44 of its 46 members were client state, and the other two left in the commit
before this one. It is client.zustand.ts now, useClientZustand, ClientZustand,
and migrateClientState in migrations/client/ where the folder already said so.

The storage key moved too, and that part is not a rename. persist reads one key
and builds an empty store when it finds nothing, so an upgrade would have come
up with no connection config, no register config and no register mapping. There
is no version bump that fixes that: migrate runs on what was read, and nothing
was read. carryFormerStorageKey copies root.zustand to client.zustand before the
store is built, and leaves the old key where it is so a build that goes back
still finds its config.

Five unit tests over the copy, each watched failing: the move itself, the old key
surviving it, the guard that stops a second launch overwriting what the user
changed since, the empty case, and storage that throws while the module graph is
still loading.

Whether that runs before persist reads is a question about module load order, so
only the running app can answer it. 02-standalone/01-persistence writes a config,
puts it back under the old key, restarts and finds it. Removing the one call from
client.zustand.ts turns exactly that test red.
It was a file of its own, client.zustand.storage.ts, and the reason was the
test: the store's own module builds the store on import, so a function inside it
cannot be called without one. That is the test choosing the file layout, which
is backwards, and it invented a filename shape the project does not use.

It sits in migrations/client/zustand.ts now, beside migrateClientState and the
version it belongs to. The storage is a parameter rather than a reach for
localStorage, because shared is imported by main too, and that also makes the
test hand it a Map instead of needing a DOM.

Same five cases, each watched failing again after the move.
43 variables held a whole store under six different names: state 26 times, z 12,
dataState twice, and one each of server and currentRootState. They are
clientZustand, serverZustand, addRegisterZustand, dataZustand, layoutZustand,
scanUnitIdZustand and scanRegistersZustand now, after the hook that produced
them.

z keeps the job it is good at. In a selector it is the state accessor, (z) =>
z.pollRate, where a longer name would bury the field being read. Holding the
whole store is the other case, and there the name says which one.

Renamed through the TypeScript language service rather than by search. state is
also the mutative draft inside set((state) => ...), 35 times in client.zustand.ts
and 40 in server.zustand.ts, often in the same file as a store variable of the
same name. A textual replace takes the draft with it; a scope-aware rename knows
which binding it is on. The drafts are untouched.
Six areas, split by what the code shares rather than by directory. Eight
criteria, of which one is new here: an assumption about modbus-serial is a
finding unless you opened the file in node_modules that implements it. The two
sharpest things this project knows about that library came from doing exactly
that, and neither was visible from src.

The conformance suite asserts nine conventions and is green, so the audit does
not look for those. It looks for what a test cannot see.

Findings are proposals, not instructions, and the skill says what each part of
one is worth. The recipe and the proposal are the two least trustworthy fields:
a recipe describing an input that does not trigger the behaviour is the most
common defect in an audit, and a proposal is a guess by someone who did not read
the rest of the file.

Two agents per area, and the second one is not a reviewer. It is handed a claim
and asked to demonstrate it wrong, which is a different job with a different
failure mode. The bar for rejection is high: a wrong finding costs one look, a
dropped correct one costs a defect in a build. No verdict counts as kept.

Ported from ploxc's audit-area, which needs a docs/map/ this repo does not have,
so the output goes to tmp/. Both references carry this branch's own failures:
the four meters that miscounted here, and the two modbus-serial findings. A
ported anecdote about a compiler teaches nothing in a Modbus client.
Six areas audited in parallel gave 28 blocking findings that were thirteen
causes. Three of the thirteen could not be seen from any single area document:
one defect was reported three times, from the function that clears, the button
that calls it, and the helper that answers the width, and none of the three
names the file where the fix belongs.

So the skill gains a step between auditing and fixing. Group the blocking
findings by cause, write it to tmp/AUDIT-clusters.md, and check with a loop that
every finding reached it.

The grouping is itself a new way to be wrong: a cluster that is really two
causes gets fixed as one and the half nobody looked at survives with the ticket
closed. The refutation reviewer is handed the grouping along with the claims and
asked to break that first.

And a cluster's size is not the largest size inside it. Every finding was sized
within one area by someone who could not see the others, so four of these
thirteen need a decision that fits in no single area.
The audit's first blocking finding. Both persisted stores validate at module
scope and reset themselves when the schema refuses, and both reported that
through notistack from there. notistack assigns its standalone enqueueSnackbar
inside the SnackbarProvider constructor, and main.tsx builds that provider in
createRoot().render(), which runs after every module has evaluated. So the call
threw out of module scope and took everything below it: no init, no event
listeners, and no render either. A user with a corrupt config got a blank window
with no UI left to clear it from.

The stores record the reset on themselves now. MessageReceiver reads the flag
and tells the user, from under the provider where there is something to tell.
It acknowledges after telling, because it mounts inside Client and Server rather
than at the root: without that, walking to Home and back reports the same reset
again.

Five tests in context/__tests__, which the audit noted was empty. Restoring the
old call turns two of them red and leaves the other three green.

Not run: the e2e suite. This changes the renderer's startup order, which is the
wrong place to skip it, but five audit agents were writing scratch specs into
src/ and e2e/ while this was being fixed and a run would have covered their
files rather than this one. It is the first thing to run against this commit.
The opening thanked the reader and said the guidelines exist to keep the
review smooth. Neither is a claim, an order or a measurement, which is
what this repo asks of a sentence. One line replaces both, and it points
at the conformance test.

Two of the ten rules told what went wrong once instead of what the rule
is. The path-alias rule named @main, @preload and @backend, none of which
appear in any tsconfig or in electron.vite.config.ts any more, and
src/backend is gone, so its whole body described a state nobody can check.
It now names the two aliases that exist and says when one retires. The
include rule kept its second sentence, which says how the test works, and
lost its first.

An action is fetched where it runs now names both shapes, after the
refactor that made a pass-through prop take the action itself.

Three em dashes and one -- left over from stripping one, each rewritten as
its own sentence. The Arduino paragraph said twice that the board is found
by USB vendor ID and skips without one.

Still standing: What will get your PR rejected repeats ground rule 3 and
two lines of Code style.
The harness reads a non-zero code as something to show the user, and 2 as
a refusal, which on PreToolUse blocks the tool call. Nothing pinned that
here, so a hook that dereferenced a bare payload would have blocked
whatever it fired on and no test would have said so.

The list of hooks under test is checked against .claude/settings.json
rather than kept beside it, or a hook wired and not listed sits outside
the claim. Both directions proven: wiring a sixth hook turns the list test
red, and dropping one optional chain in precommit-trigger turns its exit
test red.

Ported from scripts/hooks/hooks.test.ts in the ploxc repo, which pins the
same rule.
persist merges shallowly, so a persisted connectionConfig of {} replaced
the whole default sub-object rather than being filled in, and the check
over the whole state then failed on the merged result. That failure was
answered by clearStorage, which took every sibling with it: a register
mapping built by hand went along with the field that broke, and nothing
was kept to recover it from.

repairPersisted checks each field the schema names on its own, keeps the
ones that parse, defaults the ones that do not, and returns their names.
Both stores now report a ConfigReset instead of a boolean, and
MessageReceiver says which fields went rather than that something did.

The blob is copied to <key>.corrupt-<timestamp> before the reset. The
reset is what makes the app usable again and also what destroyed the
evidence, and a mapping worth hundreds of rows is worth having in a bug
report even once it is unreadable.

A state saved by a newer version went through migrate untouched and was
cast to the schema's type without ever meeting it. persist calls migrate
for any version that is not the current one, so the number is recorded
there and read once: the fields that still fit are kept, the rest reset,
and the message says where the config came from. A newer config that
loses nothing is still reported, because a field this version does not
know is a field it silently dropped.

Nine mutations, one per rule, each red on its own test. The non-object
guard survived the first attempt: its rival read a string as a record and
answered the same for every input the tests give it.
Opening the server in its own window dropped every connected master. The
second window loads the same renderer entry, so `server.zustand.ts` runs its
module-scope `init()` again, and in TCP mode that calls `createServer` per
uuid. `createServer` closed the existing `ServerTCP` and bound a new one, and
`ServerTCP.close` destroys every socket in `modbus.socks`. Measured in the
running app with a raw socket on 502: `["connected","FIN","closed
hadError=false"]`, and `["connected"]` after this change.

`createServer` now answers with the port when `_servers` already holds a
listener for that uuid on it. The rebind bought nothing: the vectors read
`_serverData` when a request arrives, so a port change is the only reason to
rebind and `setPort` owns that. `resetServer` therefore keeps its listener too,
which is the same fix for clearing a server's registers.

A refused bind was invisible in the same way. `new ServerTCP()` returns before
`listen` has finished and reports a failure as a `serverError` event rather
than a throw, so `createServer` returned a port while `_servers` held a
listener that was never up. `_bindServer` waits for `initialized` or
`serverError` before writing either map, with a timeout so a library that sends
neither cannot hang the caller. `createServer` moves to the next port and
`setPort` puts the server back where it was, saying so.

`10-split-view` connects a socket before the window opens and asserts it
survives. With the guard removed that test reports `Array [ "closed" ]`, and it
takes two windows, so no unit test can see it.
FC15 sends every coil from the opened address to the end of the range, and
the dialog seeded that list with Array(length).fill(false) without ever
reading registerData. Writing one coil therefore switched off every coil
above it that the user had not touched.

seedCoils fills the list from the rows the grid holds, so what is on screen
is what goes back out. FC5 is unaffected: main takes value[0], the coil at
the opened address.

The coil buttons now carry aria-pressed, which is what the e2e helper reads
to decide whether a click is needed. Without it the helper clicked whenever
the caller asked for TRUE, which now means it would send the opposite for a
coil the device already had on.

Verified with a mutation in each direction. seedCoils returning false for
every row turns four of the five unit tests red, and indexing from zero
instead of from the first address turns two red. Putting
Array(length).fill(false) back in the component and rebuilding turns the new
e2e test red on coil 6, with the 22 tests before it still green.
The mask sets `valid` and only the box read it, so an emptied field went out
as `Number('')`, which is 0. A lone minus, which is what the mask holds on
the way to a negative number, went out as NaN the same way. Both buttons are
now off while the field is not a value.

`handleClose` reset the field with `setValue('0')`, and `setValue` takes its
second argument as the verdict, so the reset marked a plain 0 as invalid.
That was invisible while nothing read the flag, and it would have left the
buttons off on the next address opened. The reset is now its own action.

Verified with three mutations against the new tests: dropping the guard turns
two red, replacing it with the neighbouring rule `value === ''` turns the
lone-minus test red, and a reset that marks the field invalid turns the store
test red.
The effect returned early when the register mapping named no type for the
address, and this store outlives the dialog, so the address kept whatever the
one before it used. A value then went out encoded as a type the address is
not.

writeDataTypeFor answers for both cases: the mapped type where there is one,
int16 where there is not. It reads `none` as a mapping without a type rather
than as a type, which the select does not offer either, and it takes the
default from the same constant the store starts at.

Verified with two mutations: putting the early return back turns the unmapped
address test red, and letting `none` through turns its own test red.
`errorMessage` was declared outside `_read`'s group loop, so `_logTransaction`
logged every group after a failed one with the earlier group's error. It is
`_logTransaction` that reads it outside the catch: the placeholder rows and the
snackbar are inside, and see their own group either way.

Declaring it inside the loop is the whole fix. Two tests read two groups, one
failing the first and one failing neither, and assert which error each group's
transaction carries. Hoisting the declaration back out turns the first red and
leaves the second green.
…ng one

The disconnect timeout replaces `this._client` with a fresh `ModbusRTU`, and the
constructor was the only place registering `error` and `close` on it. After one
close that never called back, connection errors went unreported and auto-reconnect
never fired again for the rest of the session.

The two registrations move into `_attachClientHandlers`, called from the
constructor and from the replacement site. `isDebugEnabled` is reset by the
replacement too and needs nothing: `connect` sets it again.

`modbusClient.test.ts` mocks the constructor to hand out one shared object, so
the replacement is the same instance with its handlers still attached and the
existing timeout test cannot see any of this. The two new tests empty the
handler record first, so what it holds afterwards is what the replacement got:
one asserts both handlers come back and that a close on the replacement still
reconnects, the other that a close answering in time re-registers nothing.
`_logTransaction` assigned `{}` over modbus-serial's transaction table, which
takes the entries for requests still in flight with it. `_onReceive` returns
early when the response's entry is gone, so an overlapping request timed out
rather than resolving: the grid kept its old values and the view showed a
timeout that never happened. `write` ends with an un-awaited `this.read()`, and
a second write arriving while that read is outstanding is the way to meet it.

Deleting the key just logged is what the assignment was for: the same entry is
not logged twice on the next call.

Three tests: the logged entry is gone, a second read logs nothing again, and an
entry the read did not log is still there afterwards. Putting the assignment
back turns the third red and leaves the first two green.
`ConnectionConfigSchema.deepPartial()` accepts a payload whose field is
explicitly `undefined`, Zod keeps the key, Electron's structured clone carries
it over the IPC hop, and `deepmerge` copied it over the stored value. The
process then ran on a config its own schema refuses:

  safeParse({ tcp: { host: undefined } })  ->  success, keys of tcp: ["host"]
  host after merge: undefined
  whole config still valid: false

`withoutUndefined` drops those keys before the merge, in
`updateConnectionConfig` and in `updateRegisterConfig`, which has the same
shape. No call site was found that sends one today: the reviewer checked all
twenty `update*Config` call sites and this closes the shape rather than a
reproduced bug.

The helper is exported because neither config holds an array, so the branch that
hands one back whole is not reachable through the two update methods. Four tests
drive it directly and four drive the methods. Removing the call turns four red,
stripping only the top level turns three, recursing into arrays turns the array
one, and dropping the null guard turns the null one.
`get_connection_config` and `get_client_state` had no caller anywhere. Measured
over all 38 channels in IPC_CHANNELS, each one's camelCase method grepped as
`.<method>(` across src/renderer and e2e:

  population: 38   no caller: ["get_connection_config","get_client_state"]

Every other occurrence of either name was a definition, a doc comment example or
the sample string in a `snakeToCamel` test. Those three now name a live channel.

`get_connection_config` held the app's only revalidation of the stored config,
and it never ran. The shape it was guarding against is closed at the merge in
the commit before this one, so nothing is lost with it. `get_client_state`
validated a value main had just produced and typed.

Thirty-six channels left. `yarn test` unchanged at 822.
`window.api` is generated from IPC_CHANNELS, so a channel nobody calls still
gets a method, a handler and a spec entry, and nothing says so. Two of them sat
that way long enough for the audit to find them, with the app's only config
repair branch inside one.

The rule reads IPC_CHANNELS, converts each name the way the preload does, and
looks for that name called as a property anywhere in src/renderer. The caller
has to be in the renderer: a channel only the e2e suite drives is one the app
does not use, and today there are none, so this costs nothing and makes that a
decision rather than a drift.

Both directions were run. Putting `get_client_state` back in IPC_CHANNELS turns
the rule red naming the channel and its method; renaming IPC_CHANNELS so the
reader finds nothing turns the population test red instead of passing empty.

CONTRIBUTING.md and CLAUDE.md now say eleven, which `grep -c '^describe('` over
the file agrees with.
`setRegisterValue` wrote straight into the entry, where `setBool` beside it
branches on whether there is one. Against a store with no entry at that address:

  TypeError Cannot set properties of undefined (setting 'value')

Reachable through `delayedSetRegister`, the only caller, which batches on a
50 ms timer. The `register_value` event proves the entry exists when it is
enqueued, not when it flushes, and `removeRegister` and `resetRegisters` are
both one click. The throw happens inside a `setTimeout` callback, so it surfaces
as an uncaught renderer error rather than a message. The race itself is read,
not reproduced.

Dropped rather than recreated the way `setBool` does. A bool entry is a value; a
register entry carries the params that say what it is, and a batched number is
nothing to build one from. The address the user deleted stays deleted.

Three tests: an entry that is there is written, one that is gone is dropped
without a throw, and the entries beside it are still written. The original line
turns two red, and recreating the entry instead turns the middle one red.
`strict` does not include it, so `record[key]` and `array[i]` were typed as `T`
and TypeScript said nothing about an index that is not there. That is why
`setRegisterValue` could write into an entry that had been deleted while
`setBool` beside it branched: the type said the entry was always there.

Enabled in all three tsconfigs. It reported 227 errors over 144 sites, which
these changes answer. Three of them were real:

  _writeCoil gave value[0] to FC5 and the schema accepts an empty coil list,
  so an empty one put undefined on the wire. It now sends nothing and says so.

  readGroupEntry and readGroupByGid read the gid out of line.split(':'), and an
  /etc/group line with fewer fields gave Number.parseInt(undefined), so a
  malformed line answered NaN where the caller expects a gid. Skipped now.

  deleteServer set selectedUuid to uuids[0]. The delete button is off for the
  main server so the list never empties, but that guarantee lived in the
  component rather than in the store, and now it is written down.

No non-null assertions were added, and an index that is provably in range got
no guard either: a branch no input reaches is a branch no test covers, no
mutation turns red, and a reader takes for a real case. The index goes away
instead. groupAddressInfos and the copy of it in the huawei spec became one
forward pass over an open block, which the 26 existing grouping tests hold to
its old answers; createRegisters returns [number, ...number[]], so the
guarantee that used to be a comment is the type FC6 reads registers[0] from.

In tests the reads became optional chaining, so a missing element fails the
assertion. Two places could not take it without hiding a failure and got a
helper that throws instead: fireClientEvent, because `?.()` on a handler that
was never registered is a test that quietly passes, and firstCallOrder.

Both new guards are reached by a test. Removing the coil guard turns its test
red; replacing the gid guard with a throw turns its test red, which is what
says the line runs.

CONTRIBUTING gains the rule under Code style.

yarn lint 0, yarn typecheck 0, yarn test 830, yarn test:e2e 572 passed.
Three things Jens asked for after the flag went on.

`@typescript-eslint/no-non-null-assertion` is an error now. Turning it on
reported 80 assertions, 69 of them in `modbusServer.test.ts`. All are gone.
`lastVector` resolves the six handlers a server vector carries and fails naming
the one that is missing, where `getHoldingRegister!(...)` said only that
something was undefined; `lastInstance` does the same for the constructor's
result. `bindResults.length > 0 ? bindResults.shift()! : true` is
`bindResults.shift() ?? true`, which answers an empty queue the same way. Three
e2e specs pulled a count out of a section title with the same regex and the same
`expect(match).toBeTruthy()` in front of the assertion; they share `sectionCount`
now, which fails with the text it did read.

CONTRIBUTING and CLAUDE no longer count the conformance rules. The figure was
edited on every rule that landed, which is how it goes quietly wrong. Same for
"sixteen channels take no argument", in the same file.

Names are spelled out. In `_read`: `gi`, `a`, `l` and `r` are `groupIndex`,
`groupAddress`, `groupLength` and `row`, and the inner `address` that shadowed
the outer one is `mappedAddress`. `d` and `p` further down are `row` and `port`.
In `serialGroup`, `gid` is `groupId` and `readGroupByGid` is `readGroupById`.
Node's own `Stats.gid` keeps its name, which three red tests said before I did.

yarn lint 0, yarn typecheck 0, yarn test 830, yarn test:e2e 572 passed.
An adapter unplugged between requests reaches the port as `close` and as
nothing else: `@serialport/stream` answers a failed read with
`close(undefined, new DisconnectedError(...))` and pushes nothing into the
stream. `startRtuServer` listened for `error` alone, so the view kept
showing a server whose port was gone.

The new listener sits beside the `error` one. It stays quiet for a close
this process caused, which `stopRtuServer` marks by clearing `_rtuServer`
and `_rtuActive` before closing the port, and for the write path, where one
unplug emits both events and the `error` listener has already reported it.

The `ServerSerial` mock had no `_serverPath`, so neither port listener was
reachable from a test. It has one now, and `fireSerialPathEvent` fails
naming the event where a missing handler would only have said something is
not a function.

Verified by mutation: removing the listener, each clause of its guard, and
the `_rtuActive` it clears, each turns a different one of the five new
tests red. Killing socat does not reproduce the trigger, because the pty
outlives the process holding it, so the disconnect itself rests on the
library's documentation of `close` rather than on a measurement here.
Removing the rule count from it left "three of them these" behind, which
reads as nothing. The clause also claimed the four bullets above are all
asserted, and `window.api` is generated is not one of the rules: the suite
checks that a channel carrying an object has a schema and that a channel
has a caller, not that the method appears by itself. Dropping the clause
says what is true and cannot go stale.
"Four things that break if you do not know them" and "Two rules no test can
see" both go stale the moment a bullet is added, and neither number tells
the reader anything the list does not. The `audit` skill names the second
heading, so it moves with it. The subpath count in the MUI rule goes the
same way: the claim is that no subpath exports those two hooks, and how
many there are does not carry it.
`PrivilegedPortModal` returned on `window.api.isServerWindow`, so in split
view the check ran in neither window: `layout.zustand` puts the main window
on the client view for as long as a server window exists, and the server
window was the one told to stay quiet. A user who splits from Home never
had it run at all, which is the path `10-split-view.spec.ts` drives.

The modal is mounted in `containers/Server.tsx` and nowhere else, and only
one window renders that container at a time, so the mount point already
answers which window owns the check and the guard only cancelled it.

The unit test that asserted the old behaviour now asserts the new one, and
putting the guard back turns exactly that test red. The manual Linux spec
gets the split path as a case; it needs a blocked port floor and someone at
the keyboard, so it has not been run.
`DeleteButton` read the address out of the form field rather than out of the
register the dialog was opened on. Changing the address and then pressing
Remove left that register alone, removed whatever sat at the typed address, and
closed the dialog as though it had worked. The `?? 'uint16'` fallback beside it
then chose the span to erase for a register that was not there.

Which of the two buttons applies follows from what the dialog holds, so they
now say so. Submit Change is off until a field is touched, Remove goes off the
moment one is, and a value typed back to what it was returns the dialog to
where it opened. A user who came to edit submits; a user who came to delete
deletes.

Dirty is measured against what the edit effect wrote into the fields, not
against the register, because that effect converts on the way in and a
conversion that did not round-trip would leave the dialog dirty on open.

Removing moves to `remove()` beside `submit()` in the store, for the reason
`submit` is there: it reads the dialog and writes through the server store. It
takes the address, type and length from the register being edited, so the
lookup by form address and its fallback are gone.

Seven mutations, each red on the tests that name it: dropping either button's
`disabled`, dropping `capturePristine` from the edit effect, letting `remove`
read the form address, dropping its edit-mode guard, keeping the old snapshot
across a second open, and calling a missing snapshot dirty.

yarn lint 0, yarn typecheck 0, yarn test 853 passed,
04-add-register-modal.spec.ts 20 passed.
The edit effect called the masked setters with one argument. Their second is
the validity of the value, and the setters do `state.valid.x = !!valid`, so
`undefined` landed as false and every field opened marked wrong.

What that looks like depends on whether the field is on screen. A visible one
paints its label as an error until the mask under it mounts and reports back,
which is the red that fades on open. A hidden one keeps the false: a fixed
register has no min, max or interval, and switching it to Generator showed
those fields empty and wrong with Submit Change disabled until both were
retyped. A generator has no value and does the same the other way.

So the flag is passed, and a field the register has nothing for is seeded from
`FIELD_DEFAULTS`, which the store's initial state and `resetToDefaults` now
read as well.

Six mutations, each red on the test that names it: dropping the flag on value,
on min and max, or on interval, and dropping either seeding. Dropping the flag
on the address survives, because `setDataType` at the end of the effect
recomputes `valid.address` without it. It stays for the contract: omitting it
says the address is invalid, and that it is rescued afterwards is an ordering
accident.

yarn lint 0, yarn typecheck 0, yarn test 857 passed, and the three specs that
drive this dialog, 03-server-config, 04-add-register-modal and 05-file-io,
76 passed.
Two tests in the bitmap spec asserted `toBeVisible` on a bit's circle and on
the register row's Edit button. A circle is visible whatever its bit is and
the Edit button whatever the value is, so the per-bit rendering of `ServerBit`
and `ServerBitMapDetail` looked covered and was not.

A circle differs between on and off in `background` and `boxShadow` only, so
it now carries `data-active`, and the register row's value carries
`server-reg-value-<type>-<address>`. Both tests read those instead. The
comment gets its testid in its read state too, which retires the
`span.MuiTypography-root` selector that CONTRIBUTING forbids.

`ServerBit` and `ServerBitMapDetail` get unit tests: which bits a value shows
as on, what a toggle writes back in both directions, that a toggle drops the
generator fields, and where an edited comment lands.

Seven mutations, each red on the test that names it: the circle's state made
constant, `readOnly` dropped from the toggle, the read-state testid removed,
the set and clear branches swapped, the generator fields carried over, the bit
order reversed, and an edited comment written under a fixed index. Two more in
the e2e specs: a circle that stops carrying its state fails the bit states
test, and a row value that never refreshes fails the toggle test.

lint 0, typecheck 0, `yarn test` 870 passed, and `03-server-config`,
`21-bitmap-settings` and `26-server-layout` together 65 passed.
`SerialPortOptionsSchema` permitted `mark` and `space`, and the RTU client
and the RTU server both offered them. `serialport_unix.cpp` has a case for
none, odd and even and a default that returns -1, and `binding.gyp` builds
that file on mac and on linux. Against a socat pty on macOS 23.6.0:

    none   opened
    even   opened
    odd    opened
    mark   ERROR: Invalid parity setting 2
    space  ERROR: Invalid parity setting 5

`ParitySchema` now holds the three, `SerialPortOptionsSchema` takes it, and
`ParitySelect` maps its options rather than a hand copy. The five strings were
written out in four places: the schema, that copy, the cast in
`setServerParity`, and `modbus-serial`'s own type, which is what `setParity`
was typed against and is why a cast could hand it anything. Both setters take
`Parity` now, and both casts are gone.

A stored config can carry either. `repairPersisted` works a top level field
at a time, so without a migration a stored `mark` costs the com port and the
baud rate beside it: with the v3 to v4 step removed, the server store comes
up on `com: ''`. Both stores bump a version and call `repairPersistedParity`,
which writes `none` only where the stored value is one the schema no longer
names.

`corruptedConfig` wrote `version: 2` into five fixtures, which is the current
version until it is not. They read the constant now, so the file keeps
testing repair rather than a migration.

Ten mutations, each red on the test that names it: `mark` back in the
schema, either migration step dropped, either version constant put back, the
repair made unconditional, its absent-parity guard removed, its walk cut
short, and each of the two guards in that walk. Two more in the e2e: the
select reading a hand list again fails on the option count, `5` against `3`,
and a parity setter that ignores its argument fails the server RTU spec.

lint 0, typecheck 0, `yarn test` 885 passed, `16-client-rtu` and
`23-server-rtu` together 62 passed.
`RegisterParamsBasePartSchema.address` was a bare number and
`RemoveRegisterParamsSchema.address` was `RegisterAddressSchema`, so the two
paths disagreed. Measured through the schemas:

    address -1     add ACCEPTS   remove refuses   sync ACCEPTS
    address 1.5    add ACCEPTS   remove refuses   sync ACCEPTS
    address 70000  add ACCEPTS   remove refuses   sync ACCEPTS
    address 65535  add ACCEPTS   remove ACCEPTS   sync ACCEPTS

That is also the schema a saved config file arrives on, through
`ServerRegistersPerUnitSchema`, and `syncRegistersWithBackend` hands the
parameters to `addRegister` unchanged. `addRegister` writes
`serverData[registerType][address + index]` into the array `resetRegisters`
builds as `new Array(65536)`, so a file naming 70000 stretches it:
`holding_registers` comes back 70001 long, and no Modbus request reaches the
part past the end.

`RegisterAddressSchema` now covers the field. The map is keyed by address as
well, and a boolean entry carries nothing else, so `ranges.ts` gains
`RegisterAddressKeySchema` and `ServerBoolSchema` and `ServerRegisterSchema`
take it. `WriteParametersSchema` and `SetBooleanParametersSchema` wrote the
same range out by hand and now import the name; that pair is a rename, so no
test names it.

The persisted store carries the same shape. `repairPersisted` works a top
level field at a time, so one register at 70000 fails `serverRegisters` and
returns it empty, which is every register on every server and unit. The store
bumps a version and calls `dropUnservableRegisters`, which deletes the entries
the schema no longer names and leaves their neighbours.

Twelve mutations, each red on the test that names it: the address back to a
bare number, either map key back to any digits, the key ceiling one short, the
migration step dropped, the version constant put back, either half of
`isServable` removed, an entry with no parameters dropped rather than kept, the
walk no longer filtering out a null, the entry shape clause removed, and the
entry emptied rather than deleted.

lint 0, typecheck 0, `yarn test` 900 passed, `03-server-config`,
`04-add-register-modal` and `05-file-io` together 76 passed.
`applyLegacyStringReplacements` ran four `replaceAll` calls over the raw file
text before `JSON.parse`, and `migrateServerConfig` and `migrateClientConfig`
both called it first. The four names read as ordinary English, so every
occurrence in a config name and in a comment went with them. Measured on a
valid v2 file, which reports `migrated: false`:

    name         -> "coils bank A"    (was "Coils bank A")
    coil comment -> "coils enable"    (was "Coils enable")
    reg comment  -> "read input_registers here, discrete_inputs too"

That text is what the store persists and what the next save writes back, so a
load and a save lose it for good.

`RegisterType` was an enum of camelCase members until `b3474fe`, and those
members were the register map's keys, so a config saved before that is the only
one naming them the old way. Versioned configs arrived at `b4f558b`, eight
months later. `renameLegacyRegisterTypeKeys` walks the parsed object and
renames keys alone, and the two v1 to v2 migrations are its only callers. What
a user types is a value in a config: a name, a comment, a string register's
contents. The keys are field names, unit ids, addresses and bit indices.

    name         -> "Coils bank A"
    coil comment -> "Coils enable"
    reg comment  -> "read InputRegisters here, DiscreteInputs too"
    v1 map keys  -> coils, discrete_inputs, input_registers, holding_registers

Ten mutations, each red on the test that names it: the rename put back over the
file text, in either config path; the call dropped from either v1 migration;
the walk no longer going down; the old key left beside the new one; a coil
renamed to the neighbouring type; the walk returning rather than continuing at
the first key it does not name; an array no longer walked; and the object check
dropped.

The test that covered the old rule asserted only that a config of four empty
maps did not throw, so it went with it.

lint 0, typecheck 0, `yarn test` 915 passed, `05-file-io` and
`14-client-config-io` together 33 passed.
`windows.main` was set nowhere but at creation. `windows.server` is nulled on
close and on `window-all-closed`; the main window had no such handler, so the
handle outlived the object it named. Every method on a destroyed
`BrowserWindow` throws, and `second-instance` calls two of them.

Only macos reaches it: `window-all-closed` quits everywhere else, so nowhere
else does the app outlive its windows. Close the window, leave Modbux in the
dock, launch it again from Finder or Spotlight, and the second process loses
the single instance lock and hands over to the first, which calls
`isMinimized()` on the dead handle. What the user gets is a modal error dialog
reading "A JavaScript error occurred in the main process" and no window.

A `closed` handler now nulls the handle, beside the server window's. `close`
would be too early: the window is alive there and a listener may still cancel
it. With the handle honest, `second-instance` can answer the case it was
written for and open a window when there is none, which is what a second launch
was asking for.

`createWindow` builds into a local and assigns once, so the guard inside
`ready-to-show` that read `windows.main === null` goes. It was written for a
null the code could not produce.

`_sendUpdate` was `send` with the per window guard removed, both carrying the
same comment about macos. One throw ended its loop, and `Object.values` puts
`main` first, so a stale main handle cost the server window its
`window_update`. It calls `send` now: one path, one guard, one comment.

The suite could not see either of these. `10-split-view` closes the server
window and leaves the main one standing, and nothing anywhere closes the last
window. `02-macos-dock` does, with a device counting the polls it is sent and a
master holding a connection to the server, so what keeps running with no window
open is measured rather than assumed: the client polls on, the server keeps its
master, and the window comes back.

Mutations, each red on the test that names it. Dropping the null-out and
dropping the `createWindow` branch both fail `launching modbux again brings the
window back`; the first also puts the error dialog back on screen. Restoring
`_sendUpdate`'s own loop fails `a destroyed main window does not cost the server
window its update`.

lint 0, typecheck 0, `yarn test` 43 files and 918 passed, `10-split-view`,
`01-persistence` and `02-macos-dock` together 27 passed.
`ConnectionConfigSchema.unitId` and `TcpPortOptionsSchema.port` were bare
numbers while `ranges.ts` exported `UnitIdSchema` and `PortSchema`. The server
states the same range on everything that arrives: all six entries of its service
vector open with `UnitIdStringSchema.safeParse`, and it is the same byte going
the other way.

What the bare schema accepted, and what the byte did with it:

    999       accepted   writeUInt8 ERR_OUT_OF_RANGE
    -5        accepted   writeUInt8 ERR_OUT_OF_RANGE
    Infinity  accepted   writeUInt8 ERR_OUT_OF_RANGE
    3.7       accepted   writeUInt8 wrote 3

The promise API wraps each call in a `new Promise` executor, so the three
RangeErrors come back as a rejection and the read path reports them. `3.7` is
the one nothing reports: the client polls unit 3 and says so nowhere.

No shipped path produces either value, so there is no CHANGELOG entry.
`UnitIdInput` masks 0 to 255 and the client's port takes `UIntInput`, whose
default ceiling is 65535. The saved client config file carries neither field:
`SaveButton` puts the unit id in the filename and nothing else. What is left for
the schema to refuse is the persisted store, where `repairPersisted` resets the
connection settings and names them, and `update_connection_config`, which
answers "Invalid request, nothing was changed".

Four mutations, each red on the test that names it: either field back to a bare
number, the unit id given `RegisterAddressSchema`, which leaves 256 and 999 as
the values that discriminate, and the port given `UnitIdSchema`, which turns the
accepting tests red instead of the refusing ones.

lint 0, typecheck 0, `yarn test` 934 passed, and `yarn test:e2e` 581 passed.
Connect a client over TCP, close the window so the app sits in the dock, open
it again: the button reads Connect while main is connected and polling, and
pressing it answers `Already connected`.

Main pushes `client_state` on a change, so a window that opens after the last
push has nothing to catch up on and starts on the store's initial disconnected
literal. `init` asks now.

`get_client_state` is the channel `51bd5a6` dropped for having no caller. It
comes back with its caller in the same commit, which is what the eleventh
conformance rule asks for. The handler hands back `client.state` and validates
nothing, because that is a value main produced and typed.

A push that lands while the answer is in flight is the newer of the two and
keeps its value. The ask sits behind a `try` because `init` runs from module
scope with nothing awaiting it, so a rejection there is an unhandled one, and
falling through leaves the literal the window already showed.

`02-macos-dock` builds the scenario and asserted only that the device kept
being read. It looks at the button now, and reads Connect without the fix. The
store tests join the four the conventions round put in `context/__tests__/`:
the answer landing, a push beating it, a rejection changing nothing, and the
configs `init` pushes before it asks.

yarn test 939 passed. yarn test:e2e 582 passed.
Three specs split the server out of Home the same way: click, then
`waitForEvent('window')`. The listener goes up when `waitForEvent` is called, so
the window can open inside the gap the awaited click leaves, and the event is
gone before anything is listening. `98-privileged-port` timed out there, at `the
split-out server window asks`.

Outside the spec, with the listener armed after the click: 2 of 3 runs timed
out, and the Server window was open in all three. Armed beside the click: 3 of
3.

The three call sites are one helper now, `splitOutServerWindow`, which also
waits for the load state each of them waited for separately.

lint 0, typecheck 0, yarn test 44 files and 939 passed. 98-privileged-port 8
passed, 10-split-view 7 passed, 03-presentation 43 passed with no screenshot
changed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant