Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
91 commits
Select commit Hold shift + click to select a range
2f4659a
fix: keep a client setting the boundary refused
Harted Sep 5, 2026
4641288
fix: keep the decimal separator out of the whole number fields
Harted Sep 5, 2026
ce7dc30
refactor: build a server's unit map where the write lands
Harted Sep 5, 2026
ec37dcd
refactor: let the server own the byte order it encodes in
Harted Sep 5, 2026
154b167
fix: keep the bools a unit carries when they are all off
Harted Sep 5, 2026
9318172
refactor: say once what the two stores both do
Harted Sep 5, 2026
ac22731
refactor: read a persisted store back through one repair
Harted Sep 5, 2026
5ed99ef
fix: tell main the mapping that replaced the whole of the previous one
Harted Sep 5, 2026
2d46045
fix: let main end a scan it started
Harted Sep 5, 2026
54694d5
fix: give both Start buttons the same answer about polling
Harted Sep 5, 2026
4173396
fix: clear the scan flags before the client_state that reports the di…
Harted Sep 5, 2026
9d9f949
fix: keep one poll chain on one connection
Harted Sep 5, 2026
2017c4a
fix: refuse a read or a write while a read loop owns the client
Harted Sep 5, 2026
c8b7503
docs: name the second poll loop in the changelog
Harted Sep 5, 2026
93badaa
refactor: one read per register type, and a loop over the ones a scan…
Harted Sep 5, 2026
1172f3d
fix: give a bit read the length it asked for
Harted Sep 5, 2026
c9a5018
fix: offer read configuration only where an address carries a data type
Harted Sep 6, 2026
bdb8179
fix: read the toolbar window for a bit type, whatever the mapping car…
Harted Sep 6, 2026
f84d5c3
chore: run yarn format
Harted Sep 6, 2026
d50553f
refactor: one generic accessor per register type, and one channel for…
Harted Sep 14, 2026
430f341
fix: count the updates the batcher is meant to flush on
Harted Sep 14, 2026
adddb2c
docs: say once what the accessor aliases guarantee, and drop what was…
Harted Sep 14, 2026
d950b14
refactor: reach the RTU server's port through a type, and listen for …
Harted Sep 14, 2026
0aa45ea
fix: give the RTU server's serial port the data bits and stop bits it…
Harted Sep 14, 2026
c1c3349
fix: a replaced RTU server no longer speaks for the one that took its…
Harted Sep 14, 2026
7bc23d7
test: assert the serial line settings a port is actually open with
Harted Sep 14, 2026
66b0d0b
fix: a cancelled connect no longer connects
Harted Sep 14, 2026
b754196
fix: a client the disconnect timeout abandons stops speaking
Harted Sep 14, 2026
5988923
fix: refuse a connect while an earlier one is still opening
Harted Sep 14, 2026
8e446bd
refactor: drop the flag that suppressed a deliberate close
Harted Sep 14, 2026
708e136
fix: an abandoned client no longer crashes main, and a cancel holds i…
Harted Sep 14, 2026
034c3f0
refactor: drop the two connection-config fields nothing read
Harted Sep 14, 2026
88a2a8b
fix: state the read and generator rules where the boundary can read them
Harted Sep 14, 2026
7b7fc89
refactor: the serial frame widths are literals, and the casts naming …
Harted Sep 14, 2026
896378b
fix: a register map key is an address, and a config file was the door
Harted Sep 14, 2026
334416d
refactor: Windows belongs to main, and the rule that says so reads el…
Harted Sep 14, 2026
d41d8d2
fix: a backend message goes to the window it is about
Harted Sep 14, 2026
7477fbb
test: the lists that name a channel are held to each other
Harted Sep 14, 2026
c29ce7c
fix: the two channels that take a server uuid and nothing else state it
Harted Sep 14, 2026
909b4b4
chore: close the two dependabot alerts in the lockfile
Harted Sep 14, 2026
b6563f2
test: the server store's migration is reachable, and every step it ru…
Harted Sep 14, 2026
f25541f
fix: a datetime is milliseconds wherever its range is stated
Harted Sep 14, 2026
b6e0de8
fix: the server row reads a timestamp through the decoder shared alre…
Harted Sep 14, 2026
5c9a183
refactor: two helpers answer over their whole range, not over their c…
Harted Sep 14, 2026
62336cb
refactor: the year a datetime decoder reads is already inside its range
Harted Sep 14, 2026
2b38b92
fix: the date picker offers the window the register it writes can carry
Harted Sep 14, 2026
c5d6c92
fix: a value nothing can encode is refused rather than written as a zero
Harted Sep 14, 2026
765c05e
fix: the date picker writes the date it shows, and shows only a date …
Harted Sep 14, 2026
7483270
refactor: one function converts a unit's bools, and each caller walks…
Harted Sep 14, 2026
73b9e62
fix: a refused config names the register and the field
Harted Sep 14, 2026
58c1bfa
fix: a string stops at the last register of the group it was read in
Harted Sep 14, 2026
ba082b0
fix: a refusal names one fault per line, and reaches every fault it h…
Harted Sep 14, 2026
ec54f61
refactor: one action empties a server, and the two buttons call it
Harted Sep 15, 2026
5b7d76b
test: the reset test asserts what happens, not an order nothing depen…
Harted Sep 15, 2026
da5d67d
refactor: a value read off the store is read where it can change the …
Harted Sep 15, 2026
ea42a92
refactor: one list says which data types a scale applies to
Harted Sep 15, 2026
fc8c176
fix: a selector takes the reference and the comparing happens in a memo
Harted Sep 15, 2026
d312423
refactor: the save buttons write the version constant they already ex…
Harted Sep 15, 2026
d7fe87f
fix: the port fix is believed only by the kernel, and a dismissal sur…
Harted Sep 15, 2026
e396155
fix: a check whose reason is gone writes nothing, rather than writing…
Harted Sep 15, 2026
6b36d5b
fix: Add & Next with nothing free above marks the address it is left on
Harted Sep 15, 2026
cb3e900
test: the address helper texts carry a testid, and Add & Next takes a…
Harted Sep 15, 2026
7851371
fix: an expanded bitmap row is as tall as the panel inside it, and th…
Harted Sep 15, 2026
390afe9
fix: a row gets a panel's height only while it still has a panel, and…
Harted Sep 15, 2026
45d66d7
fix: one read at a time, and the port says which one
Harted Sep 15, 2026
04eefc6
fix: turning read configuration on reads
Harted Sep 15, 2026
e526e45
fix: a refused write logs no transaction
Harted Sep 15, 2026
f145199
fix: a write logs the transaction it filed
Harted Sep 15, 2026
115f921
fix: a dropped register frees the addresses it stood on
Harted Sep 15, 2026
fe99749
fix: the used addresses are rewritten for every unit the drop walks
Harted Sep 15, 2026
d438b60
fix: a register reaches the grid only once the server has it
Harted Sep 15, 2026
57e8f17
docs: the disconnect catch says why it disconnects
Harted Sep 15, 2026
fc51437
fix: a generated register reaches the grid with the value it was given
Harted Sep 15, 2026
f605a95
refactor: the section count reads the unit id the way everything else…
Harted Sep 15, 2026
1594a9d
fix: no free port says so in a snackbar
Harted Sep 15, 2026
8f8f685
refactor: a register row draws the value it was handed
Harted Sep 15, 2026
5826dfb
refactor: opening a configuration no longer yields per unit id
Harted Sep 15, 2026
796af43
refactor: one place hands the user a file
Harted Sep 15, 2026
fbafce7
refactor: the scan dialogs live beside the view that mounts them
Harted Sep 15, 2026
2622a65
fix: a register the server holds is drawn as the value it holds
Harted Sep 15, 2026
4968137
fix: the unit id scan logs the requests it sends
Harted Sep 15, 2026
a75e3a3
fix: a bit toggle reads the word the toggle before it wrote
Harted Sep 15, 2026
fb0e428
fix: a write or a scan refused for a shut port reports the disconnect
Harted Sep 15, 2026
8decf69
fix: a register added in the split out server window survives a restart
Harted Sep 16, 2026
0d202c2
fix: a window taking the server view back asks what the RTU server is…
Harted Sep 16, 2026
bfcbe59
docs: the changelog line for the RTU status light
Harted Sep 16, 2026
2b7f481
docs: the e2e generator interval is in seconds and caps at ten
Harted Sep 16, 2026
127ec92
fix: the split out server window no longer writes the client store back
Harted Sep 16, 2026
b7d1f91
refactor: a component that owns nothing is a file again
Harted Sep 16, 2026
ba50bbf
refactor: the bitmap store declares its type in a sibling file
Harted Sep 16, 2026
141061b
refactor: the twenty cosmetic findings the triage never named
Harted Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/hooks/__tests__/guards.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ describe('git-restore-guard', () => {

it('fires on a checkout naming a path that exists', () => {
const cwd = dirtyRepo()
expect(fire('git-restore-guard', { command: 'git checkout -- a.ts' }, { cwd })).toContain('a.ts')
expect(fire('git-restore-guard', { command: 'git checkout -- a.ts' }, { cwd })).toContain(
'a.ts'
)
})
it('fires on git restore', () => {
const cwd = dirtyRepo()
Expand Down
5 changes: 4 additions & 1 deletion .claude/hooks/__tests__/prose-trigger.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ const HOOK = join(dirname(fileURLToPath(import.meta.url)), '..', 'prose-trigger.

/** What the hook says, or '' when it declined. Throws when it exits non-zero. */
const fire = (toolInput, sessionId = `test-${Math.random()}`) => {
const payload = JSON.stringify({ session_id: sessionId, ...(toolInput ? { tool_input: toolInput } : {}) })
const payload = JSON.stringify({
session_id: sessionId,
...(toolInput ? { tool_input: toolInput } : {})
})
const out = execFileSync('node', [HOOK], { input: payload, encoding: 'utf8' })
return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : ''
}
Expand Down
4 changes: 1 addition & 3 deletions .claude/hooks/__tests__/test-trigger.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,7 @@ describe('test-trigger stays quiet on', () => {

describe('test-trigger reaches a test written through Bash', () => {
it('fires on a heredoc naming a test path', () =>
expect(
fire({ command: "cat > src/shared/__tests__/a.test.ts <<'EOF'\nx\nEOF" })
).not.toBe(''))
expect(fire({ command: "cat > src/shared/__tests__/a.test.ts <<'EOF'\nx\nEOF" })).not.toBe(''))
it('fires on a heredoc carrying a test call', () =>
expect(fire({ command: "python3 - <<'PY'\ns = \"it('works', () => {})\"\nPY" })).not.toBe(''))
it('stays quiet on a heredoc that writes neither', () =>
Expand Down
4 changes: 2 additions & 2 deletions .claude/hooks/bulk-edit-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ const REMINDER =
'A scripted edit fails quietly in three directions: it removes what you did not name, it ' +
'eats half a sentence in prose and leaves no symbol behind, or it raises before writing and ' +
'changes nothing. After it runs: `git diff --stat -- <the files you named>` (empty means ' +
'nothing happened), `git diff | grep \'^-\' | grep -E \'const |function |export \'` (what ' +
'left, by name), and for prose `git diff | grep -E \'^[-+][[:space:]]*(//|\\*)\'`. Not ' +
"nothing happened), `git diff | grep '^-' | grep -E 'const |function |export '` (what " +
"left, by name), and for prose `git diff | grep -E '^[-+][[:space:]]*(//|\\*)'`. Not " +
'`--word-diff`, which prefixes every line with a space so those filters return nothing.'

/** Every firing after it asks the question instead of repeating the rule. */
Expand Down
2 changes: 1 addition & 1 deletion .claude/hooks/git-restore-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const rest = atRisk.length > 10 ? ', and more' : ''
const REMINDER = isStash
? `These files hold unstaged changes: ${listed}${rest}. \`git stash\` with nothing to stash ` +
'succeeds anyway, so a later `git stash pop` takes whatever was already on the stack, ' +
'possibly another branch\'s work. To carry work to another branch, `git checkout <branch>` ' +
"possibly another branch's work. To carry work to another branch, `git checkout <branch>` " +
'brings it along when nothing conflicts. To measure another commit, use a worktree.'
: `These files hold unstaged changes: ${listed}${rest}. \`${verb(command)}\` naming a path ` +
'restores from the index, and for an unstaged file the index is HEAD, so it deletes ' +
Expand Down
47 changes: 24 additions & 23 deletions .claude/skills/audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ finding, including the one-line obvious ones.

Split by what the code shares, not by directory size. One area per run.

| area | what it is |
| --- | --- |
| `modbus` | `main/modules/modbusClient.ts`, `modbusServer.ts` and `modbusServer/` |
| `boundary` | `main/ipc.ts`, `preload/`, `shared/types/ipc.ts`, `main/state.ts`, `main/windows.ts` |
| `shared` | `shared/` minus `types/ipc.ts`: schemas, migrations, pure helpers |
| `stores` | `renderer/src/context/` |
| `client-ui` | `renderer/src/components/client/` |
| `server-ui` | `renderer/src/components/server/`, `components/shared/`, `containers/` |
| area | what it is |
| ----------- | ------------------------------------------------------------------------------------ |
| `modbus` | `main/modules/modbusClient.ts`, `modbusServer.ts` and `modbusServer/` |
| `boundary` | `main/ipc.ts`, `preload/`, `shared/types/ipc.ts`, `main/state.ts`, `main/windows.ts` |
| `shared` | `shared/` minus `types/ipc.ts`: schemas, migrations, pure helpers |
| `stores` | `renderer/src/context/` |
| `client-ui` | `renderer/src/components/client/` |
| `server-ui` | `renderer/src/components/server/`, `components/shared/`, `containers/` |

## Before you start

Expand All @@ -35,17 +35,17 @@ Apply all eight. Do not merge them and do not skip the cosmetic ones.

1. **What the conformance suite cannot see.** The store-versus-component IPC
rule, the folder-per-component judgement, and anything else CONTRIBUTING
states as prose under *The rules no test can see*.
states as prose under _The rules no test can see_.
2. **Duplication.** Search for the shape, not the name. Two functions doing one
job often share no word, so a search for what one is called returns neither.
Report identical copies too, because they diverge later. A duplication claim
carries its own burden of proof, under *Verification*.
carries its own burden of proof, under _Verification_.
→ [WHY: a meter is a claim too](./references/a-meter-is-a-claim.md)
3. **Dead code.** Unused exports, unreachable branches, config entries pointing
at deleted paths, comments naming files that are gone.
4. **Deferred comments.** Every `TODO`, `FIXME`, `for now`, `later`, `until we`,
with its exact location and text.
5. **Test coverage.** What is *not* covered. Think like someone with a field
5. **Test coverage.** What is _not_ covered. Think like someone with a field
device, not like the author: a unit id of 0 and of 248, an address at 65535
with a data type needing four registers, a serial port that disappears
mid-read, a config file from two versions ago.
Expand All @@ -60,6 +60,7 @@ Apply all eight. Do not merge them and do not skip the cosmetic ones.
alone. Count code lines and test lines separately: a file that looks like the
worst offender is sometimes a thin one with a large test block bolted on, and
the two call for opposite conclusions.

7. **Architecture fit.** Does this block RTU over TCP, a second client, the
gateway idea, or anything `CHANGELOG.md` says is coming?
8. **What modbus-serial actually does.** For `modbus`, `boundary` and `shared`:
Expand Down Expand Up @@ -113,11 +114,11 @@ and its output) · `proposal` · `recipe` if reproduced.
**severity — what the app does to a user decides it, not how much it annoys
you.**

| | |
| --- | --- |
| | |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **blocking** | A user gets a wrong answer, a crash, or lost configuration. A malformed frame reaching the socket. A register read as the wrong type. Also: dead code that makes something look covered when it is not. |
| **annoying** | Right behaviour, wrong construction. Duplication, a rule enforced in one of two places, untested logic. Nothing a user sees today; the next change here is where it bites. |
| **cosmetic** | Neither. A stale comment, an unused export, a name that misleads. |
| **annoying** | Right behaviour, wrong construction. Duplication, a rule enforced in one of two places, untested logic. Nothing a user sees today; the next change here is where it bites. |
| **cosmetic** | Neither. A stale comment, an unused export, a name that misleads. |

**size — how much work the proposal is, not how large the defect is.** **S** is
one file and no decision. **M** touches several call sites or needs a small
Expand Down Expand Up @@ -168,14 +169,14 @@ Two exceptions, where the burden runs the other way:

## What a finding is worth once it is written

| part | trust |
| --- | --- |
| the file, the symbol, the evidence | high, and verifiable |
| the claim, if reproduced | high |
| the claim, if only read | good |
| **the recipe** | **low** — the most common defect in an audit is a recipe describing an input that does not trigger the behaviour |
| **the proposal** | **low** — a guess by someone who did not read the rest of the file |
| any summary or state | **low** — a compression, and compressions interpret |
| part | trust |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| the file, the symbol, the evidence | high, and verifiable |
| the claim, if reproduced | high |
| the claim, if only read | good |
| **the recipe** | **low** — the most common defect in an audit is a recipe describing an input that does not trigger the behaviour |
| **the proposal** | **low** — a guess by someone who did not read the rest of the file |
| any summary or state | **low** — a compression, and compressions interpret |

Open the file, reproduce, then decide. Being written down is not evidence.

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/audit/references/a-meter-is-a-claim.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ fix. Expanding the glob with `globSync` answers the question that was asked; a
rule about the shape of the string answers a different one.

**It matched the name and not the thing.** Renaming `rootState` to `clientState`
with a regex renamed the `clientState` *field* on the store as well, producing
with a regex renamed the `clientState` _field_ on the store as well, producing
`clientState.clientState`. The language service knows which binding an
identifier is on; a search and replace knows only the characters.

Expand Down
10 changes: 5 additions & 5 deletions .claude/skills/audit/references/one-bug-three-reports.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
Six areas were audited in parallel. The agents could not see each other, which is
what makes a refutation reviewer worth having and also what produces this:

| the area | names it as | claim |
| --- | --- | --- |
| modbus | `modbusServer.ts removeRegister` | clears a fixed 24 registers where `addRegister` wrote `length ?? 10` |
| server-ui | `DeleteButton` and `addRegister.zustand.ts submit` | removing a utf8 register blanks unrelated registers past its end |
| shared | `addressGrouping.ts getRegisterLength` | returns 24 with no `nextAddress`, and takes no `length` parameter at all |
| the area | names it as | claim |
| --------- | -------------------------------------------------- | ------------------------------------------------------------------------ |
| modbus | `modbusServer.ts removeRegister` | clears a fixed 24 registers where `addRegister` wrote `length ?? 10` |
| server-ui | `DeleteButton` and `addRegister.zustand.ts submit` | removing a utf8 register blanks unrelated registers past its end |
| shared | `addressGrouping.ts getRegisterLength` | returns 24 with no `nextAddress`, and takes no `length` parameter at all |

One defect. Three blocking findings. Three different files named, and **none of
the three is where the fix belongs**: the width is stated in several places that
Expand Down
20 changes: 10 additions & 10 deletions .claude/skills/handover/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ never from what was interesting.
Two paths. `/compact` writes its own summary of the conversation, so step 4 has
no reader on that path and step 4b replaces it.

| next | steps |
| --- | --- |
| `/clear`, or a new session | 1, 2, 3, 4, 5 |
| `/compact` | 1, 2, 3, 4b, 5 |
| next | steps |
| -------------------------- | -------------- |
| `/clear`, or a new session | 1, 2, 3, 4, 5 |
| `/compact` | 1, 2, 3, 4b, 5 |

## 1. Inventory, by destination

Expand All @@ -36,13 +36,13 @@ Then sort each item by **where it belongs**, not by how interesting it was.
`/todo` owns the destinations: invoke it rather than deciding here. Two things
it does not own:

| what it is | where it goes |
| --- | --- |
| what it is | where it goes |
| ------------------------------------------------------ | ------------------------------------ |
| state: branch, commits, what is green, what is running | the prompt, or the compact paragraph |
| the next instruction | the user writes it, step 5 |
| the next instruction | the user writes it, step 5 |

**One question makes it mechanical.** For each item: *if a future session needed
this and I were gone, where would it look?* A file, then it goes in the file.
**One question makes it mechanical.** For each item: _if a future session needed
this and I were gone, where would it look?_ A file, then it goes in the file.
Nowhere, because it only matters for the next hour, then the prompt. **"It would
ask me" is the item most likely to be dropped and the one that must be written.**

Expand Down Expand Up @@ -94,7 +94,7 @@ standing list here, so the paragraph carries itself: state has files, the repo
answers its own shape, and a sentence repeating either spends the space that was
actually at risk.

What is left is a judgement about *this* session. Hand over the line to type and
What is left is a judgement about _this_ session. Hand over the line to type and
nothing else:

```sh
Expand Down
14 changes: 7 additions & 7 deletions .claude/skills/precommit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: Run the checklist before committing or merging — read the diff, l
## While you work

**`git add` before you mutate anything.** `git checkout` naming a path restores
from the *index*, which on an unstaged file is HEAD — so the command undoing one
from the _index_, which on an unstaged file is HEAD — so the command undoing one
mutation deletes everything else you wrote in that file.

**A scripted edit leaves no name behind.** After any edit you did not type line
Expand Down Expand Up @@ -39,7 +39,7 @@ git diff --staged
Read **every** changed file, and untracked files in full: a new file has no diff,
and is where a fresh copy of something the project already owns lands.

Then, against `CONTRIBUTING.md` *Code style* and `CLAUDE.md`:
Then, against `CONTRIBUTING.md` _Code style_ and `CLAUDE.md`:

- **A store selector returning an object** is a whole-store subscription wearing
a selector's clothes. One selector per field.
Expand Down Expand Up @@ -86,11 +86,11 @@ for minutes, and running it per commit is how a branch stops being worked on.
**Report what every step above produced.** A waiver covers the permission, never
the checklist.

| what you are about to do | ask first? |
| --- | --- |
| `git commit` | no |
| `git push`, `gh` | **yes** — the line is whether it leaves the machine |
| merge | **yes**, and show the squash message first |
| what you are about to do | ask first? |
| ------------------------ | --------------------------------------------------- |
| `git commit` | no |
| `git push`, `gh` | **yes** — the line is whether it leaves the machine |
| merge | **yes**, and show the squash message first |

## 6. Commit

Expand Down
12 changes: 6 additions & 6 deletions .claude/skills/precommit/references/what-a-script-moved.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

**A split left six section banners naming the wrong thing.** `AddRegister.tsx`
was 900 lines and 27 components; a script cut it into five files on each `const`
line. A banner sits *above* the component it introduces, so every one of them
line. A banner sits _above_ the component it introduces, so every one of them
stayed behind with the component before it.

| the banner | what ended up under it |
| --- | --- |
| `// Min Max components` | `IntervalInputForward` |
| `// Fixed Or Generator` | `CommentField` |
| `// MAIN`, `// Comment`, `// Shared submit logic` | nothing, end of file |
| the banner | what ended up under it |
| ------------------------------------------------- | ---------------------- |
| `// Min Max components` | `IntervalInputForward` |
| `// Fixed Or Generator` | `CommentField` |
| `// MAIN`, `// Comment`, `// Shared submit logic` | nothing, end of file |

Jens found the first by reading the diff. The sweep that found the other five
was a script too:
Expand Down
Loading
Loading