Skip to content

feat(compile): one table list, every server counted, and no output driven twice - #1114

Merged
JulioSergioFS merged 7 commits into
task/DOPE-615-size-the-io-image-from-the-projectfrom
task/DOPE-615-B-server-exposure-and-duplicate-outputs
Sep 15, 2026
Merged

JulioSergioFS merged 7 commits into
task/DOPE-615-size-the-io-image-from-the-projectfrom
task/DOPE-615-B-server-exposure-and-duplicate-outputs

Conversation

@JulioSergioFS

Copy link
Copy Markdown
Contributor

Stacked on #1093 — targets that branch, so the diff here is only group B's own commit.

Four things that belong together, because the first is what makes the rest safe to add.

One canonical table list

"Which tables exist, which IEC prefix each stores, in which unit, on which runtime" was written out in five places — the two area sets, image.conf's keys, the bare-metal macros and the S7comm buffer enum — and a sixth was about to be added for the S7comm sizer. Every way five copies can disagree is silent: a table in one list and not another is an area sized to zero, a prefix paired wrongly sizes the wrong storage, a unit written differently is a factor of eight nobody sees until an address stops answering.

All five now derive from middleware/shared/utils/io-image/tables.ts. Adding a table is one line there.

No output moves. The list is in image_tables.h declaration order, and filtering it to the entries carrying a macro yields exactly the order defines.h already emitted — checked by a test, because a gratuitous reorder rebuilds every firmware for nothing.

Every server counts, not just Modbus

serverExposure looked for protocol === 'modbus-tcp' and nothing else, so a project with an S7comm server and no Modbus one sized nothing at all from its servers.

Still the first server of each protocol, deliberately: both config emitters ship one file built from the first one carrying a config, so a second never reaches the device and sizing for it would reserve memory nothing can use.

An S7comm data block sizes the table it names, converting sizeBytes to that table's elements and adding startBuffer. Two conversions meet there and pull opposite ways, so it is written once and asserted from both directions.

An S7comm block backs what it covers, inputs included

The opposite of the Modbus answer, and the difference is a fact about the protocols rather than a preference. Modbus discrete inputs and input registers are read-only to the master by the protocol, so exposing an %IX through Modbus cannot put anything into it. S7comm has no such restriction and the plugin implements none — write_buffer_to_openplc_journal dispatches every buffer type, BUFFER_TYPE_*_INPUT included. An S7 client writes an input table as readily as an output one, so BR14's question answers yes in both directions here.

Two POUs cannot drive one output

checkIfLocationExists reads one variable list, so each POU passes on its own while IEC located addresses are global. The generated code then assigns to the same storage from two places and the last write in the scan wins, decided by POU order.

Checked per slot, so overlapping located arrays are caught at the slot they share — and reported once per pair, because a 4000-element array declared twice is one mistake. Outputs only: two POUs reading one input is ordinary, and sharing a memory address is what memory is for.

The same fact is answered while editing, through the amber glyph the alias scan already uses.

Plus: a VPP manifest channel's address prefix is now checked against the eight the package schema admits. It arrives as an unchecked string through an addressMapping typed unknown, and a manifest declaring %MW would have memory allocated for it as if a module produced it.

Verification

3317 tests across the areas touched, 100% statements/lines/functions on compute-io-image.ts, tsc and validate:arch clean, compare-surfaces "match": true over 1117 files.

🤖 Generated with Claude Code

…iven twice

Four things that belong together, because the first is what makes the rest
safe to add.

ONE CANONICAL TABLE LIST. "Which tables exist, which IEC prefix each stores,
in which unit, on which runtime" was written out in five places -- the two
area sets, image.conf's keys, the bare-metal macros and the S7comm buffer
enum -- and a sixth was about to be added for the S7comm sizer. Every way five
copies can disagree is silent: a table in one list and not another is an area
sized to zero, a prefix paired wrongly sizes the wrong storage, a unit written
differently is a factor of eight nobody sees until an address stops answering.
All five now derive from `middleware/shared/utils/io-image/tables.ts`, and
adding a table is one line there.

No output moves. The list is in image_tables.h declaration order, and
filtering it to the entries carrying a macro yields exactly the order
defines.h already emitted -- checked by a test, because a gratuitous reorder
rebuilds every firmware for nothing.

EVERY SERVER COUNTS, NOT JUST MODBUS. serverExposure looked for
`protocol === 'modbus-tcp'` and nothing else, so a project with an S7comm
server and no Modbus one sized nothing at all from its servers. It now
dispatches per protocol. Still the FIRST server of each protocol, deliberately:
both config emitters ship one file built from the first one carrying a config,
so a second never reaches the device and sizing for it would reserve memory
nothing can use.

An S7comm data block sizes the table it names, converting `sizeBytes` to that
table's elements and adding `startBuffer`. Two conversions meet there and pull
opposite ways -- bytes become elements, while a BOOL table's element index
becomes bit addresses -- so it is written once and asserted from both
directions.

AND AN S7COMM BLOCK BACKS WHAT IT COVERS, INPUTS INCLUDED, which is the
opposite of the Modbus answer. The difference is a fact about the protocols,
not a preference: Modbus discrete inputs and input registers are read-only to
the master BY THE PROTOCOL, so exposing an %IX through Modbus cannot put
anything into it. S7comm has no such restriction and the plugin implements
none -- write_buffer_to_openplc_journal dispatches every buffer type,
BUFFER_TYPE_*_INPUT included. An S7 client writes an input table as readily as
an output one, so BR14's question answers yes in both directions here.

TWO POUS CANNOT DRIVE ONE OUTPUT. checkIfLocationExists reads one variable
list, so each POU passes on its own while IEC located addresses are global.
The generated code then assigns to the same storage from two places and the
last write in the scan wins, decided by POU order. computeIoImage already
walks every located variable project-wide and the ST text path cannot bypass
it, so the check goes there. Outputs only: two POUs reading one input is
ordinary, and sharing a memory address is what memory is for.

Checked per slot, so overlapping located arrays are caught at the slot they
share rather than only when their base addresses match -- and reported once
per pair, because a 4000-element array declared twice is one mistake.

The same fact is answered while editing, through the amber glyph the alias
scan already uses, so the user hears it before they press build rather than
after.

A VPP manifest channel's address prefix is checked against the eight the
package schema admits. It arrives as an unchecked string through an
`addressMapping` typed `unknown`, and a manifest declaring `%MW` would have
memory allocated for it as if a module produced it -- the one thing BR14 says
nothing can do. The channel is dropped with a warning rather than throwing:
one bad channel must not take down the device screen.

Verified: 3317 tests across the areas touched, 100% statements/lines/functions
on compute-io-image.ts, tsc and validate:arch clean, and compare-surfaces
"match": true over 1117 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • development

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 73a15f63-ba58-4f68-86d4-e7df5149eabf

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@marconetsf marconetsf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity on every point, here and inline: 🔴 Major (must fix before merge) · 🟡 Changes required (should be addressed) · 🟢 Nit (optional) · ❓ Question. Each carries a confidence out of ten.

One 🔴 Major, three 🟡 Changes required, four 🟢 Nits and one ❓ Question, all inline.

I ran the validation this PR does not get from CI — it targets a feature branch, so ci.yml never fired and only CodeRabbit ran:

  • npx tsc --noEmit — clean.
  • npx jest src/backend/shared/compile src/middleware/shared/utils/io-image396 passed, 15 suites.

The consolidation in (a) does what it claims, and I checked it rather than trusting the test. I reproduced all five derived lists from the diff's pre-image and compared them against what IMAGE_TABLES now produces:

derived list membership order verdict
IMAGE_AREAS_BAREMETAL identical identical no change
IMAGE_AREAS_RUNTIME_V4 identical changed no behavioural change — only ever consumed through .has()
image.conf TABLES identical identical key/prefix/unit identical row for row, byte-identical output
PROCESS_IMAGE_MACROS identical identical no macro moves, so no gratuitous firmware rebuild
S7CommBufferTypeSchema identical changed no validation change — z.enum is a set test and .options has no consumer

The two reorders are harmless for the reasons above, but the PR body does not mention them and the S7CommBufferTypeSchema one is worth a sentence there, since a reader seeing a persisted enum reorder will want to know someone checked.

The byte-to-element conversion is written once and is correct for every width. extentForDataBlock divides by BYTES_PER_ELEMENT then multiplies by ADDRESSES_PER_ELEMENT: 128 bytes is 1024 bits / 128 bytes / 64 words / 32 dwords / 16 lwords. Both directions are asserted, and startBuffer is correctly added in elements before the address multiply, which matches the unit the schema comment on startBuffer documents. A partial trailing element is floored, which is the right direction.

Duplicate outputs: I traced the cases end to end rather than reading test names. Confirmed caught: two POUs on one %QX0.0; two declarations in one POU; a POU-local against a configuration global; overlapping located arrays at a shared slot that is neither base address; and the ST text path, which writes back into the same pou.data.variables so there is no second declaration channel to bypass. Correctly silent for %IX, %IW, %MW and %MX. "Once per pair" is genuinely implemented — the probe breaks on the first colliding slot.

Two gaps I could not close, neither of which I am filing as findings: variables in globalVariableLists are not walked by locatedVariables (pre-existing, shared with the BR14 check, and the docstring defends it); and there is no test pairing a literal %QW5 against an alias that resolves to %QW5.

One thing I could not verify at all: extentForDataBlock ignores bitAddressing. If the runtime plugin reinterprets start_buffer as a bit index when that flag is set, a BOOL block would be sized eightfold. The plugin source is not in this repo and no test covers bitAddressing: true.

🟢 Nit · confidence 9/10 — the edit-time glyph is missing on the side the compile check explicitly covers. Raising it here because the file is not in this diff, which is the finding. useDuplicateOutputLocations deliberately scans configuration globals, and the compile check has a passing test for refuses a POU-local colliding with a configuration global. But only _molecules/variables-table/editable-cell.tsx consumes the hook — _molecules/global-variables-table/editable-cell.tsx still calls useProjectAliasBindings alone. So the user editing the global side of exactly the collision this feature is about sees nothing until they build. It is the same three-line change as in the sibling cell.

const extent = extentForDataBlock(table, block.mapping.startBuffer, block.sizeBytes)
if (extent <= 0) continue
claim(sizes, table.prefix, extent)
markBacked(backed, table.prefix, 0, extent)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Major · confidence 9/10 · an S7comm block backs addresses it does not cover

const extent = extentForDataBlock(table, block.mapping.startBuffer, block.sizeBytes)
if (extent <= 0) continue
claim(sizes, table.prefix, extent)
markBacked(backed, table.prefix, 0, extent)

extent is a high-water mark — startBuffer + elements, scaled to addresses. That is exactly right for claim, because the image is contiguous. It is not the covered range, and markBacked(..., 0, extent) marks [0, extent).

So a block { type: 'int_input', startBuffer: 100, sizeBytes: 8 } backs %IW0 through %IW103, when the plugin only ever writes int_input[100..103]. AT %IW0 : INT then compiles clean with nothing producing it and reads zero forever on the machine — which is precisely the declaration BR14 exists to refuse.

This contradicts the file's own rule in two places. producerClaims marks per slot (markBacked(backed, prefixOf(parsed.cls), parsed.linear, 1)) and its comment says "a producer at slot 9 alone still needs ten slots — but slots 0 through 8 have nothing behind them". The unbacked loop says "an address in a gap between two producers is within the image and still has nothing behind it, which is the case BR14 exists for". And this function's own docstring claims per-slot semantics: "A block mapped onto int_input is a producer for the %IWs it covers".

The Modbus path a few lines up may legitimately mark from 0 — Modbus segments always start at IEC index 0. S7comm blocks do not, which is what startBuffer is for.

Fix: back only the covered range. Having extentForDataBlock return { start, end } is cleaner than exporting the scale factor, then markBacked(backed, table.prefix, start, extent - start).

No test catches this: still refuses an input address the block does not reach uses startBuffer: 0 and probes an address above the extent, and adds the start buffer to the extent asserts only sizes. The missing case is a block at startBuffer: 100 leaving AT %IW0 : INT in unbacked.

break
}
}
for (let slot = parsed.linear; slot < parsed.linear + slotCount; slot++) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes required · confidence 9/10 · reintroduces the O(N) blow-up the memory path was just fixed to avoid

for (let slot = parsed.linear; slot < parsed.linear + slotCount; slot++) {
  if (!owners.has(slot)) owners.set(slot, { scope, variableName: name })
}

This runs unconditionally for every %Q declaration. Thirty lines below, the memory branch carries the comment explaining why the identical pattern was removed there:

No markBacked here, deliberately. […] it was dead at a cost: the loop ran once per declared element, so AT %MW0 : ARRAY [0..10000000] OF WORD inserted ten million Set entries in the main process before the platform compiler ever got to refuse the size.

AT %QW0 : ARRAY [0..10000000] OF WORD now does exactly that, and worse — a Map entry with an object value per slot rather than a Set entry — in the Electron main process, before the compiler is reached. The probe loop above is safe because it breaks at the first collision; this one has no escape.

The repo already has the right primitive, and it currently has no non-test callers: slotRangesOverlap(a, aSlots, b, bSlots) in src/middleware/shared/utils/iec-address/registry/address-space.ts. Keeping a per-prefix list of { start, end, scope, name } intervals and comparing ranges is O(declarations²) at worst, which is nothing, and it matches the helper's documented semantics including the %QX0.0 ARRAY[0..9] against %QX0.6 case.

: `${issue.first.scope} and ${issue.second.scope}`
return (
`Two variables drive the same output: "${issue.first.variableName}" and ` +
`"${issue.second.variableName}" (${where}) both cover slot ${issue.slot} of ` +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes required · confidence 9/10 · the message reports a number the user cannot map back to their code

`"${issue.second.variableName}" (${where}) both cover slot ${issue.slot} of ` +

issue.slot is parsed.linear, and for bit classes parseAddress computes byte * 8 + bit. So two variables at %QX3.2 produce "both cover slot 26 of %QX" — the user has to divide by eight in their head to get back to the address they typed. %QX is the most common duplicate-output case by far.

The address is already being carried: DuplicateOutput.location is populated at the push site and read nowhere. The unbacked message next door gets this right by echoing issue.location.

Fix: format the slot back into an address so a bit slot renders as %QX3.2, or at minimum include issue.location for the second declaration and add the first's location to the DTO. The tests assert only that both names appear, so nothing catches this.

// the variable is alias-bound, the literal address when manual. The
// combobox `value` is the same string, so picking an alias option (whose
// value is the alias name) or typing a literal both operate on `location`.
const otherWriters = (duplicateOutputs.get(locationValue) ?? []).filter(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes required · confidence 9/10 · the tooltip empties itself in the most common case

const otherWriters = (duplicateOutputs.get(locationValue) ?? []).filter(
  (name) => name !== variable?.name,
)

DuplicateOutputMap is ReadonlyMap<string, readonly string[]> — bare names — so this filters the other writers by name. But the whole point of the feature is that the two declarations are usually in different POUs, where the same name is entirely ordinary: run, motor_on, out.

When both are called the same thing the filter removes every entry and the tooltip renders as "Output %QX0.0 is also driven by . IEC located addresses are global, so the last write in the scan would win — the compiler refuses this." — while the amber glyph still shows.

Fix: have the hook store { scope, name } instead of bare names and exclude the current row by scope and name together. That also lets the tooltip say which POU, which is the information the user actually needs to go and fix it. Neither this hook nor this branch has any test.


interface Cache {
pous: unknown
globals: unknown

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Nit · confidence 9/10 · cache fields typed unknown, and no test for the hook

interface Cache {
  pous: unknown
  globals: unknown
  map: DuplicateOutputMap
}

The hook this is modelled on, use-project-alias-bindings.ts, types the same fields PLCPou[] and PLCVariable[] | undefined. unknown costs nothing at runtime but gives up the compiler's check that the identity being compared is the identity being read, which is the only thing keeping this cache correct.

There is also no test file for this hook, so the cache invalidation, the %Q-only predicate and the tooltip branch above are all unasserted. One test building the map for two POUs — an input pair that must be absent and an output pair that must be present — would cover the useful part.

* The BOOL tables are the only ones whose address unit and storage unit
* differ, and rounding UP is what keeps the slots of a partial byte
* addressable. */
export function elementsFor(table: Pick<ImageTable, 'unit'>, count: number): number {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Nit · confidence 9/10 · two new exports with no production callers

grep -rn 'elementsFor\|tableForPrefix' src outside this file returns only the new test. Neither is used by generate-image-conf.ts, generate-defines.ts or compute-io-image.ts.

Worth noting it is not a missed call site: generate-defines.ts keeps its own firmwareCount (Math.ceil(slots / 8) * 8, bits padded to a byte multiple), which is a genuinely different conversion from elementsFor (bits to bytes). The functions simply have no consumer yet. Either wire them into one, or drop them so the module's exported surface is the surface something actually depends on.

return out
return out.filter((channel) => {
if (isValidManifestPrefix(channel.addressPrefix)) return true
// Warned rather than silent: the channel disappears from the screen, and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Nit · confidence 7/10 · the warning repeats on every render

Dropping the channel rather than throwing is the right call and the comment defends it well. But resolveModuleChannels is a pure resolver called per slot and per render, not a one-shot load step, so a manifest with one bad channel produces an unbounded stream of identical console lines — which makes the log less useful rather than more, and works against the stated goal that "without this line there is nothing anywhere saying why".

Dedupe with a module-level Set keyed on ${moduleId}:${channel.name}:${prefix}, or surface it once where the manifest is loaded.

Separately, I could not verify the eight-prefix list against the package schema — grep -rn addressPrefix --include=*.json finds no manifest schema in this repo, so the claim that the schema admits exactly those eight rests on the comment. If the schema lives in openplc-packages, a pointer to it in the comment would let the next reader check.

]

for (const block of blocks) {
// A system area may be enabled with no mapping yet, which publishes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Question · confidence 6/10 · should a disabled S7comm server back input addresses?

serverExposure picks the first server with protocol === 's7comm' && s7commSlaveConfig, mirroring generateS7commConfig's predicate exactly — I checked, they are identical — and config.server.enabled is never consulted. For Modbus the existing docstring defends that choice, and it is harmless there because Modbus exposure only backs outputs.

With (c), an S7comm block now vouches for %IX, %IW, %ID and %IL as well. So a server with server.enabled: false — which ships a config the runtime will not serve — makes AT %IW7 : INT compile clean with genuinely nothing producing it.

Is that intended? Every test uses server: { enabled: true }, so either answer is currently unasserted. If the answer is that enabled should gate the S7comm branch, it is a one-line change plus a test; if it is that a disabled server still counts, that belongs in the docstring beside the Modbus reasoning.

JulioSergioFS and others added 5 commits September 14, 2026 16:59
Marcone's review on #1114. One blocker and three changes required.

AN S7COMM BLOCK BACKED ADDRESSES IT DOES NOT COVER. `extent` is a high-water
mark -- startBuffer plus elements -- which is right for sizing, because the
image is contiguous. It is not the covered range, and `markBacked(..., 0,
extent)` marked from zero. A block at startBuffer 100 therefore vouched for
%IW0 through %IW103 while the plugin only ever writes 100..103, so
`AT %IW0 : INT` compiled clean with nothing producing it and read zero forever
on the machine. That is the declaration BR14 exists to refuse, and it
contradicted the per-slot rule this same file applies in two other places.

extentForDataBlock now returns { start, end }: `end` sizes, `start` backs. The
Modbus path above may legitimately mark from zero, because its segments always
start at IEC index 0 -- S7comm blocks do not, which is what startBuffer is for.

THE DUPLICATE-OUTPUT SCAN NO LONGER WALKS EVERY ELEMENT. It kept a slot ->
owner map, one Map entry with an object value per declared element, in the
Electron main process: `AT %QW0 : ARRAY [0..10000000] OF WORD` inserted ten
million of them before the platform compiler got to refuse the size. That is
the same blow-up the memory branch thirty lines below carries a comment about
having removed. It now keeps one range per declaration and compares with
`slotRangesOverlap`, the primitive the registry already owns and had no
non-test caller for.

THE MESSAGE REPORTS AN ADDRESS. `slot` is linear within the prefix space, so
for a bit class it counts bits and two variables at %QX3.2 were reported as
"slot 26" -- leaving the user to divide by eight to get back to what they
typed, in the commonest duplicate-output case there is. Both locations are now
carried on the DTO and the overlap point is rendered through `formatAddress`
rather than by reimplementing the bit maths.

AND THE EDIT-TIME TOOLTIP STOPS EMPTYING ITSELF. The map held bare names, so a
cell excluding itself by name removed the other writer too whenever both were
called the same thing -- which is the common case, because the two
declarations are usually in different POUs and `run` or `out` is ordinary in
each. It rendered as "is also driven by ." with the amber glyph still showing.
The map now holds { scope, name }, the cell excludes by both, and the tooltip
names the POU, which is what the user needs in order to go and fix it.

Tests for all four, since none had any: the block that backs nothing below its
start and the control that does, the %QX3.2 message, a ten-million-element
array that has to stay under two seconds, and six cases for the hook including
two POUs whose variables share a name.

3327 tests, tsc and validate:arch clean, mirror byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…xporting dead code

Marcone's nits on #1114, and an answer to the question he raised with them.

A DISABLED S7COMM SERVER DOES NOT BACK. `serverExposure` never consulted
`server.enabled`, which was harmless for Modbus because that path only backs
outputs. With S7comm blocks backing inputs too, a server switched off -- one
the runtime will not serve -- made `AT %IW7 : INT` compile clean with
genuinely nothing producing it, which is the declaration BR14 exists to
refuse.

It still SIZES, deliberately: `generateS7commConfig` ships the config
regardless of `enabled`, so the storage that file describes has to exist. That
the emitter ignores `enabled` looks like a defect of its own and the Modbus
emitter has the same shape; settling that is what would let a disabled server
stop sizing as well. Until then, sizing follows the file that ships and
backing follows what will actually run, which is written down where the
decision is made.

THE MODULE STOPS EXPORTING WHAT NOTHING USES. `elementsFor` and
`tableForPrefix` had no production callers. `elementsFor` is dropped outright:
it converts bits to elements in a codebase whose whole point is that the
editor stopped converting, so keeping it is an invitation to reintroduce the
factor of eight. `tableForPrefix` becomes `tableForKey`, which is the lookup
the sizer actually performs -- it was doing it inline with `IMAGE_TABLES.find`
on the key -- so the exported surface is now the surface something depends on.

THE MANIFEST WARNING FIRES ONCE PER CHANNEL. `resolveModuleChannels` is a pure
resolver called per slot and per render, not a one-shot load step, so a
manifest with one bad channel produced an unbounded stream of identical lines
-- which makes the log less useful rather than more, working against the
reason the line exists. Deduped on name and prefix, with a test that calls the
resolver three times and expects one line.

And the cache fields are typed rather than `unknown`, matching the sibling
hook. `unknown` costs nothing at runtime and gives up the one check keeping
the cache correct: that the identity compared is the identity read.

4708 tests, 100% on compute-io-image.ts, tsc and validate:arch clean, mirror
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The epic branch scoped Modbus exposure to the target after this branch left it,
and this branch generalised the same function from one protocol to two. Either
side taken whole loses the other: keep only this one and a bare-metal board is
sized and BACKED again from a slave config it will never receive; keep only the
epic's and the S7comm walk disappears. The resolution applies the gate per
protocol -- `modbusTcpServer` for the Modbus find, `s7Server` for the S7 one --
which is what "one flag per protocol" already meant, now that there are two.

Independently, not all-or-nothing. A Runtime v4 target that speaks Modbus but
not S7 sizes from the Modbus config and ignores the S7 one.

The backing half is the half that matters. The exposure VOUCHES for the
addresses it covers, so without the gate `AT %IW2 : INT` passes the BR14 check
on a target where nothing whatsoever produces it -- "inside the image" and "has
a producer" both answered by a file the target never receives. The existing
tests covered that for Modbus only; two new ones cover the S7 path and the
independence of the two flags, and both were confirmed to fail when the gate is
removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The build log reported how MANY areas were sized and never which contributor
set any of them. That is the one question the number cannot answer on its own:
producers, a Modbus slave config and an S7comm one can each size the same area
and the image takes the largest, so `%QW = 1024` might be the program's own I/O
or a `bufferMapping` nobody has opened in a year -- and which it is decides what
the user changes. Without it the only way forward is to guess.

It bites hardest on a project that already exists on disk. A new project grows
its producers under the user's eye; an imported one arrives with both server
configs already in it and nothing anywhere says so.

`computeIoImage` now returns `origins` beside `sizes`, written by the same
`claim` that writes the size, so the two cannot disagree. Four sources:
`producers`, `modbus-server`, `s7comm-server`, and `declarations` -- the last
one memory-only, because memory is its own producer (BR14/FR24) while an input
or output declaration is checked against the image and never grows it (FR02).

DETERMINISTIC, like the sizes it describes (FR07). `claim` overwrites only on a
strictly larger number and the contributors run in a fixed order, so a tie
always names the same one. The lines come out in IMAGE_TABLES order -- the
order `image.conf` is written in and the runtime header declares -- so a reader
goes down the log, the file and the header in step. Areas that came out at zero
are left out: they have no number to attribute, and all fourteen every build
would bury the handful that carry something.

Emitted once, before the branch, so it serves bare metal and v4 alike for the
same reason the sizer is called there. Gated on `sizesTheImage`: v3 and the
simulator keep their firmware defaults, and a line there would describe an
image neither receives.

Twelve tests on the sizer and two on the pipeline. Each new invariant was
confirmed to fail when broken -- tie order, table order, the origin filter, the
gate. The gate test needed `%MW7` rather than an output address to mean
anything: with no producer in the project an output sizes nothing, so the first
version passed whether the gate was there or not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ci-format` and `ci-lint` never ran on this PR -- the workflows are gated on a
`development` base and this targets the epic branch, so the only check reported
is CodeRabbit, which skips. Running the gate locally found nine files failing
`prettier --check` and four `simple-import-sort/imports` ERRORS, which fail CI
rather than warn.

Formatting and import order only; `--fix` output, nothing rewritten by hand.
Behaviour is unchanged and the tests confirm it: 450 pass in the editor, 149 in
the web mirror. Surfaces re-verified byte-identical after the reformat, 1117
files, zero diffs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JulioSergioFS pushed a commit that referenced this pull request Sep 15, 2026
`lint / Lint Check` was red on this PR with ONE error --
`simple-import-sort/imports` at compute-io-image.ts:51, from the
`parseDimensionRange` import added at the bottom of the block in the previous
commit. `format`, `sync` and `complete-build` were reported as "skipping"
because they run behind it, so two more failures were invisible: `prettier
--check` fails on compute-io-image.test.ts and target-capabilities/index.ts.

All three are fixed here: eslint --fix output plus prettier --write, no
behaviour change. The exact CI commands now pass -- `eslint "./src/**/*.{ts,tsx}"`
reports 0 errors (253/263 warnings, which do not block) and prettier --check is
clean across src.

This was invisible from the stacked PRs, which target this branch: their
workflows are gated on a `development` base, so #1113/#1114 and #758/#759 run
no format, lint, sync or build check at all. This PR is where the chain is
actually tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marcone, on the workplan: IEC 61131-3 does not forbid declaring the same
located variable in two POUs, so the editor will not forbid it either. Where
the standard does not restrict, neither do we -- which write survives is the
programmer's call. B5 said "fail the compile naming both"; that half is
dropped. B6, the amber glyph at edit time, stays exactly as it is.

So `duplicateOutputs` leaves the gate that aborts the build and gets its own
block at `level: warning`. The detection is untouched: the glyph needs it, and
it is the same computation.

The wording had to move with it, in four places that all asserted a refusal
that no longer happens -- including the tooltip, which ended in "the compiler
refuses this" and would have been a plain lie on screen. The compile message
now states the consequence instead of issuing an instruction: the addresses are
global, so the last write wins and POU order decides which, and the fix is
offered conditionally ("if that is not what you meant").

ONE NEW TEST, because nothing covered this at all -- the review had already
noted the existing ones only check that both names appear. It asserts the build
SUCCEEDS, that exactly one warning comes out carrying both names, and that the
sentence never says "refuse". Confirmed to fail on both ways this could
regress: putting duplicateOutputs back in the abort condition, and putting the
level back to error.

Editor: prettier, eslint (0 errors), tsc, 414 tests. Web mirror: 321 tests.
Surfaces byte-identical, 1117 files, zero diffs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JulioSergioFS
JulioSergioFS merged commit 34646a5 into task/DOPE-615-size-the-io-image-from-the-project Sep 15, 2026
1 check passed
@JulioSergioFS
JulioSergioFS deleted the task/DOPE-615-B-server-exposure-and-duplicate-outputs branch September 15, 2026 13:05
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.

2 participants