feat(compile): one table list, every server counted, and no output driven twice - #1114
Conversation
…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>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
marconetsf
left a comment
There was a problem hiding this comment.
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-image— 396 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) |
There was a problem hiding this comment.
🔴 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++) { |
There was a problem hiding this comment.
🟡 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
markBackedhere, deliberately. […] it was dead at a cost: the loop ran once per declared element, soAT %MW0 : ARRAY [0..10000000] OF WORDinserted 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 ` + |
There was a problem hiding this comment.
🟡 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( |
There was a problem hiding this comment.
🟡 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 |
There was a problem hiding this comment.
🟢 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 { |
There was a problem hiding this comment.
🟢 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 |
There was a problem hiding this comment.
🟢 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 |
There was a problem hiding this comment.
❓ 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.
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>
`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>
34646a5
into
task/DOPE-615-size-the-io-image-from-the-project
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.hdeclaration order, and filtering it to the entries carrying a macro yields exactly the orderdefines.halready emitted — checked by a test, because a gratuitous reorder rebuilds every firmware for nothing.Every server counts, not just Modbus
serverExposurelooked forprotocol === '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
sizeBytesto that table's elements and addingstartBuffer. 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
%IXthrough Modbus cannot put anything into it. S7comm has no such restriction and the plugin implements none —write_buffer_to_openplc_journaldispatches every buffer type,BUFFER_TYPE_*_INPUTincluded. 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
checkIfLocationExistsreads 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
addressMappingtypedunknown, and a manifest declaring%MWwould 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,tscandvalidate:archclean,compare-surfaces"match": trueover 1117 files.🤖 Generated with Claude Code