Skip to content

feat: VPP compile pipeline + signing + debug resolver + per-target pin mapping - #795

Merged
thiagoralves merged 74 commits into
developmentfrom
feat/vpp-compile-pipeline-port
Jun 4, 2026
Merged

feat: VPP compile pipeline + signing + debug resolver + per-target pin mapping#795
thiagoralves merged 74 commits into
developmentfrom
feat/vpp-compile-pipeline-port

Conversation

@marconetsf

@marconetsf marconetsf commented May 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Started as the VPP compile-pipeline port (BoardInfoResolver + target.platformOptions); grew into the full VPP feature surface plus several adjacent fixes that all flow through the same hardware/compile path.

Companion: openplc-web PR #483 — restores byte-identity on the shared zones.

Compile pipeline

  • BoardInfoResolver unifies hals.json + installed VPP packages into one canonical lookup. Editor (real PackageManagerModule) and web (no-op stub today) both wire through it.
  • Per-core precompiled archive isolation for strucpp runtime headers. Toolchain driven via argv. Precompile sources stashed up-front for crash recovery. Parallelism capped at os.cpus().length.
  • New shared helper backend/shared/compile/steps/resolve-board-selection.ts — replaces the inline-duplicated lookup the two repos used to carry separately.
  • Arduino HAL paths, upload, Modbus defines, FQBN derivation all resolve through BoardInfoResolver.resolve(boardTarget) now (works for legacy hals entries AND VPP-installed boards).
  • Per-board Modbus defines block from the VPP screen for arduino-cli targets (generateModbusDefines); runtime-v3/v4 keep routing via conf/modbus_slave.json.

VPP catalog + signing

  • Browse Catalog tab inside Package Manager — searchable card list of remote packages with editor-version compatibility gating (minEditorVersion vs APP_VERSION), install/update/uninstall actions.
  • VPP catalog backend wired (autonomy-edge CDN; local-mock toggle for offline dev).
  • Ed25519 signature verification on install (verifyPackageSignature, trusted-keys registry). VPP install path through packages:install-from-url IPC.
  • Symlinks / non-regular entries rejected during verification.
  • VPP plugin packaging via main process.

Debugger

  • Declarative DebugSpec resolver — debug-channel selection lives in the manifest (hals.json or VPP), the resolver evaluates preconditions / enabledWhen / $ref lookups against runtime state and produces a DebugConnectionConfig. Same flow for runtime-v3 TCP, runtime-v4 WebSocket, simulator, and bespoke Modbus channels.
  • Simulator debug-serial handshake unblocked (was choking on a pin-mapping override).

Per-target pin mapping

  • DevicePinMapping switched from a flat DevicePin[] to { pinsByBoard: Record<string, DevicePin[]> } so switching Mega ↔ MKR ↔ back doesn't lose pin configuration.
  • devices/pin-mapping.json on-disk schema accepts both the new dict shape and the legacy flat array (loaded as the active board's pins so older projects keep working).
  • Variable-table location dropdown gated by active target's capabilities (Arduino's %QX0.0 no longer shown when the active target is a runtime-v4 board, etc.).

Vendor screens

  • Expandable-card + toggle-switch layout for form sections.
  • Honors password / ip-address / mac-address field types in form-layout.
  • Conditional visibility honored; field DOM ids scoped per section.
  • Save trigger unified: build/debug always saves the full project (replaces the lossy effect-driven editingState gate); gated on the new canEdit capability.

Devices dropdown / boards

  • Boards grouped by source VPP package (built-ins on top), with searchable filter.
  • Vendor heading + first-keystroke focus retention fixed.
  • DropdownSearchInput atom extracted for reuse.

Start screen

  • Per-project 3-dot menu on the recent-projects cards: "Remove from list" (immediate, disk untouched) + "Delete project" (confirm modal, recursive disk delete).
  • Refetches from projects.json after failed open so dead entries get evicted.

Bug fixes (incidental — same area)

  • Modbus TCP build on VPP Arduino boards (ESP8266/ESP32) — boardInfo.halSourceFile resolves via BoardInfoResolver, --library precompiledLibDir arg threaded for arduino-cli discovery, undef Arduino min/max macros before strucpp headers, -I avr-libstdcpp order fix.
  • c_blocks STRING/WSTRING standardized through strucpp wrappers.
  • Monaco no longer swallows Space in EditContext-based editors.
  • Toolchain hard-fails when arch subdir property is missing (instead of producing a confusing later error).
  • package-manager errors surface in a modal (no more silent failures on import/uninstall).

Test plan

  • npm run lint, npm run test, npm run validate:arch — all green
  • npm run build — clean (Linux, macOS, Windows CI)
  • Shared Surface Sync gate green against the companion web PR
  • Per-target pin mapping crash fix verified (was crashing simulator builds with devicePinMapping.filter is not a function)
  • Smoke build + upload on a built-in board (Uno / Mega) and a VPP-provided board (SLM-RP4)
  • Verify Modbus defines emitted from the VPP screen for an Arduino baremetal target (ESP32 + ESP8266); confirm Modbus TCP link
  • Walk the Browse Catalog UI end-to-end: search, install, version switch, uninstall
  • Validate the start-screen 3-dot menu: Remove from list and Delete project (with confirmation)
  • Switch target board with custom pin configurations and confirm both targets retain their pins

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added remote VPP package catalog browser with installation support
    • Board-scoped pin mapping and per-board device configuration
    • Vendor device settings editor with conditional field visibility and pin-based GPIO support
    • Debug connection configuration for simulators and runtimes
    • Project deletion from the start-screen menu
    • Module-based IO mapping with per-channel mode selection
  • Bug Fixes

    • Library installation failures no longer block compilation
    • Variable location updates now correctly handle re-selection of the same address
    • Platform option state properly scoped to active board selection
  • Improvements

    • Enhanced device board search and filtering in configuration UI
    • New UI components for better form layouts and collapsible sections
    • Improved error messages for missing toolchain and configuration issues

marconetsf and others added 16 commits May 23, 2026 15:05
Two pieces that prepare the compile pipeline to consume VPP definitions
uniformly with hals.json boards:

BoardInfoResolver
=================
New src/backend/editor/hardware/board-info-resolver.ts looks up a board
by name across installed VPP packages first and falls back to
hals.json, returning a normalised BoardBuildInfo (platform FQBN, HAL
source file, compiler flags, defines, libraries, platformOptions).
HardwareModule exposes it via getBoardBuildInfo so the compiler module
can replace direct hals.json reads with a single VPP-aware lookup.

platformOptions
===============
VPP target.platformOptions (Nano cpu=atmega328|atmega328old, Mega
cpu=atmega2560|atmega1280, board-specific upload methods, etc.) are
new metadata that a manifest can declare so the editor can render
labelled dropdowns for FQBN sub-options. The middleware/shared/ports/
types.ts exposes PlatformOption + PlatformOptionValue interfaces;
PackageManifest, BoardInfo, and DeviceConfiguration gain the
corresponding fields (platformOptions on the manifest device target,
platformOptions on BoardInfo, selectedPlatformOptions on
DeviceConfiguration).

Frontend wiring
===============
The device Zustand slice exposes setSelectedPlatformOption(key, value)
and clearSelectedPlatformOptions(). setDeviceBoard now clears
selectedPlatformOptions when the board actually changes —
platformOptions are board-specific and a cpu=atmega328old pick on Nano
makes no sense after switching to Mega. mergeDeviceConfigWithDefaults
forwards selectedPlatformOptions so loading a project that predates
the field returns the same record reference across selectors (a fresh
literal would trigger an infinite Zustand re-render loop).

The hardware module forwards manifest.target.platformOptions onto the
flat BoardInfo only when the manifest actually declares some, so the
UI's `platformOptions?.length` gate stays tight for boards that don't
expose variants.

Tests cover BoardInfoResolver (legacy + VPP + path traversal +
runtime-v4 + flag composition) and the new device slice actions
(set/clear/board-change clear/preserve-same-board).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compile every strucpp-touching TU under <build>/src/ with the board's
toolchain at -std=gnu++17 before arduino-cli runs, then ask arduino-cli
to compile the rest of the sketch normally and link against the
resulting libOpenPLCUserLib.a.

Rationale: mbed/Renesas/STM32 cores default to gnu++14 with exceptions
disabled; strucpp requires gnu++17 with exceptions. Forcing gnu++17
globally through arduino-cli's compiler.cpp.extra_flags poisons the
core's own variant.cpp compile because Arduino.h macros (abs, round,
min, max) collide with C++ stdlib headers pulled in transitively.
Splitting strucpp into a precompiled library keeps the gnu++17 +
exceptions surface contained.

Compiler module
===============
New methods on CompilerModule:
  - parseShowPropertiesOutput / extractToolchainProperties: extract
    recipe templates from arduino-cli --show-properties without
    actually compiling, cached per FQBN.
  - applyPlatformOptions: compose effective FQBN from VPP selectors in
    menu-declaration order so arduino-cli's build cache stays warm.
  - ensureResponseFileStubs: create empty stubs for @response_file
    references the recipe embeds but arduino-cli only generates lazily
    during a real compile (ESP32 build_opt.h/file_opts, STM32duino
    build.opt). Without these stubs gcc treats a missing @path as a
    literal positional argument and fails with "cannot specify '-o'
    with '-c' ... with multiple files". Regex matches both POSIX and
    Windows-style absolute paths.
  - handlePrecompileUserLib: pre-compile loop. Reads src/*.cpp except
    arduino.cpp, builds each through the resolved recipe with trailing
    -std=gnu++17/-fno-rtti plus VPP cxx_flags, ar them into
    libOpenPLCUserLib.a, then moves sources into precompile/sources/
    so arduino-cli won't recompile them. Object files are listed in
    source order to keep the archive deterministic. Throws an
    actionable error when compiler.path or compiler.ar.cmd is missing
    from --show-properties.
  - installAsArduinoLibrary: stage the archive as an Arduino library
    under os.tmpdir() with precompiled=full. Path is space-free
    because arduino-cli tokenises --build-property values on
    whitespace, and pid-suffixed so concurrent compiles of the same
    board across processes do not delete each other's staging.

handleCompileArduinoProgram now resolves BoardBuildInfo via
BoardInfoResolver, composes the effective FQBN with applyPlatformOptions,
runs the pre-compile + library install, then asks arduino-cli to compile
with --fqbn pinning the variant and --build-property compiler.libraries.
ldflags=-L<archDir> -lOpenPLCUserLib (arduino-cli does not auto-emit -L/-l
for precompiled=full libs). VPP cxx_flags propagate to both the pre-compile
and arduino-cli paths so ModbusSlave (still ridden by arduino-cli) sees
a consistent compile environment; internal -std=gnu++17/-fno-rtti stays
pre-compile-only. The detection for the bundled AVR libstdcpp include
covers arduino:megaavr alongside arduino:avr — both share avr-gcc and
lack <cstdint>.

handleGenerateCBlocksCode now writes the dynamic c_blocks_code.cpp
into <build>/src/ regardless of runtime so the pre-compile pipeline
picks it up with gnu++17. The boardRuntime parameter is preserved on
the signature (renamed _boardRuntime) so caller orchestrators keep a
stable API; a future runtime divergence may reactivate it.

Baremetal refactor
==================
ModbusSlave.cpp used to include strucpp's debug_dispatch.hpp directly,
pulling C++17 templates into a TU arduino-cli compiles in the core's
default standard. The strucpp invocations now live in
arduino_runtime_glue.cpp behind five extern "C" wrappers
(openplc_debug_array_count, openplc_debug_elem_count,
openplc_debug_size, openplc_debug_read, openplc_debug_set).
ModbusSlave speaks plain C against a stable ABI and parses in any
standard.

The static c_blocks_code.cpp baseline drops its strucpp includes; when
the project declares C/C++ POUs the dynamic version is emitted into
<build>/src/ where the pre-compile pipeline handles it with gnu++17.
ModbusSlave.h drops a duplicate scan_counter declaration that clashed
with the C-linkage one already in arduino_runtime_glue.h.
Baremetal.ino includes OpenPLCUserLib.h so arduino-cli's library
discovery picks up the precompiled archive.

show_properties_dummy.ino (introduced in the previous commit) is the
stub sketch arduino-cli compiles against so {var} interpolations in
platform.txt/boards.txt resolve without needing real project sources.

Tests cover parseShowPropertiesOutput, applyPlatformOptions
(defaults, user overrides, menu order), extractToolchainProperties
(cache hit + incomplete recipe error), handlePrecompileUserLib
(source filtering, archive order, missing toolchain props, includes
substitution), installAsArduinoLibrary (layout + pid isolation), and
ensureResponseFileStubs (regex on POSIX/Windows paths, dedup,
existing-file preservation, relative-path rejection).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handleCompileArduinoProgram was setting compiler.libraries.ldflags
(-L<archDir> -lOpenPLCUserLib) but missed `--library <precompiledLibDir>`.
arduino-cli's library discovery is header-based: without the staging
directory on its library search path, the `#include <OpenPLCUserLib.h>`
in Baremetal.ino resolves to nothing ("Alternatives for OpenPLCUserLib.h:
[]"), so the .a never enters the link line and the build fails with
"fatal error: OpenPLCUserLib.h: No such file or directory".

The ldflags property addresses the link side; --library addresses the
discovery side. Both are required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ver and stage the precompiled archive per-core

Three coupled issues surfaced when actually building VPP-installed
boards end-to-end. Each is fixed below.

VPP boards crash on direct hals.json lookups
============================================
handleGenerateArduinoCppFile read halsFileContent[boardTarget]['source']
directly. Post-VPP migration hals.json only carries Simulator and the
two Runtime targets, so every other board returns undefined and
"Cannot read properties of undefined (reading 'source')" surfaces
during Step 11. The compile and upload steps had the same shape:
handleCompileArduinoProgram passed halsContent[boardTarget] (undefined)
into buildArduinoCliCompileArgs, and the post-compile flow used
halsContent[boardTarget]['platform'] for both the simulator HEX path
and arduino-cli's --fqbn argument.

All three sites now consult BoardInfoResolver:
  - handleGenerateArduinoCppFile copies from info.halSourceFile
    (resolver picks hals.json or the installed VPP manifest).
  - handleCompileArduinoProgram synthesises the BoardHalsCompileEntry
    from info inside the method. boardHalsContent stays on the
    signature for API stability but is no longer the data source.
  - compileProgram resolves info once via a lazy getResolvedBoardInfo
    helper and feeds info.platform into the simulator HEX path
    derivation and into handleUploadProgram.

Precompiled archive subdir varies per core
==========================================
arduino-cli's precompiled-lib resolver picks the archive subdir from
a different platform property depending on the core: build.mcu for
AVR ("atmega2560"), build.architecture for mbed ("cortex-m7"),
build.arch for everything else. Writing only to src/<arch>/ worked
on mbed but missed on Mega ("Precompiled library in .../src/atmega2560
not found").

handlePrecompileUserLib now returns archCandidates (a deduped lowercase
list of every property that could name the subdir), and
installAsArduinoLibrary lays the .a under every candidate. The first
candidate doubles as the canonical archDir used for compiler.libraries.
ldflags -L injection. The compileEntry derived from info skips the
toolchainArch single-string return value entirely.

Simulator pin mapping should not depend on the project state
============================================================
The simulator HAL expects PINMASK_DIN/DOUT/AIN/AOUT defined, but the
values are a property of the virtual device — not of the user's
project. The UI already hides the pin-mapping table when Simulator
is selected; defines.h generation needed the same rule so a stale
"D0" saved when the user was previously testing a real board would
not poison the build with "'D0' was not declared in this scope".

New static CompilerModule.synthesizeSimulatorPinMapping(boardEntry)
parses the comma-separated default_* strings from hals.json into
typed DevicePin entries. handleGenerateDefinitionsFile substitutes
this for the on-disk devicePinMapping when boardRuntime === 'simulator'.
pin-mapping.json on disk stays untouched so a board switch back to
Mega/Nano restores the user's wiring.

Tests cover synthesizeSimulatorPinMapping (parse + empty + trailing
comma) and installAsArduinoLibrary's archCandidates layout (multiple
subdirs written for AVR-style boards).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…canonical declaration

Two divergent Zod schemas described `hals.json` entries:

  - `compiler/types.ts`: `compiler: z.enum([...])`, required `updatedAt` /
    `version`, missing `preview` and `specs`.
  - `hardware/types.ts`: `compiler: z.string()`, required `preview` and
    `specs`, missing `updatedAt` / `version` / `arch`.

Neither was ever `.parse()`'d — they served as type-only contracts — so
the divergence had been silently growing. Real `hals.json` entries today
carry `preview` + `specs` but not `updatedAt` / `version` / `arch`.

Consolidates the canonical schema in `hardware/types.ts`:

  - `compiler` keeps the closed enum (`'arduino-cli' | 'openplc-compiler' | 'simulator'`)
    so future VPP toolchains have to declare a member here rather than
    leak through as a free string.
  - `updatedAt`, `version`, and `arch` become `.optional()` — they were
    aspirational in the previous compiler/types declaration and the
    shipped data doesn't carry them.
  - Every consumer-visible field (preview, specs, all flag arrays,
    define, user_*, max_data_size, board_manager_url) stays in place.

`compiler/types.ts` re-exports `BoardInfoSchema`, `HalsFileSchema`,
`BoardInfo`, and `HalsFile` from `../hardware/types` so existing import
paths under `backend/editor/compiler` keep working.

Tests + tsc green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l handshake

`synthesizeSimulatorPinMapping` was replacing the project's
`devicePinMapping` with the simulator hals.json `default_*` layout
whenever the target board was the OpenPLC Simulator. That layout is
68 entries — 24 digital inputs, 8 analog inputs, 24 digital outputs,
12 analog outputs — and the digital output range includes pins 14-19,
which on the ATmega2560 are the Serial1 / Serial2 / Serial3 TX/RX
pins.

`simulator.cpp`'s `hardwareInit()` calls `pinMode()` on every entry in
the resulting pinmasks, and `updateInputBuffers()` runs `digitalRead`
/ `analogRead` over the full input range each scan cycle. On avr8js
(roughly an order of magnitude slower than silicon) the cycle budget
overflows, `modbusTask()` doesn't get a slot inside the editor's
`DEBUG_GET_MD5` retry window, and the debugger fails to connect with
"Failed to get MD5 hash after retries". Compile + firmware load
succeed end-to-end; the wedge is purely in the runtime cadence.

Reverts to using `devicePinMapping` directly — the user's configured
pins, typically a small subset — which the simulator handles inside
the response budget.

Removes the three tests that exercised the helper.

Known regression re-opened: when a user switches from a board with
named pin labels (Uno's `D0`, `D5`, …) directly to the Simulator
without editing pin-mapping, the compile breaks with `'D0' was not
declared in this scope` because the Mega2560 HAL only knows numeric
identifiers. The pin-mapping table is hidden in the UI for the
Simulator target so the user can't fix it from there. A future
targeted fix should sanitise stale named entries (or scope the
override to only fire when the existing mapping is invalid for the
selected board) rather than substituting the entire mapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…le-pipeline-port

# Conflicts:
#	src/backend/editor/hardware/hardware-module.ts
Replaces the four exec(string) call sites that drove the arduino-cli
toolchain recipe through a host shell (extractToolchainProperties, the
precompile compile loop, the ar archive step, and the bonus
checkArduinoCliAvailability case) with spawn-based argv invocations
via execFile. The previous form passed arduino-cli's POSIX-quoted
recipe — e.g. `'-DUSB_MANUFACTURER="Unknown"'` — through cmd.exe on
Windows, which does not consume single quotes, so the quote characters
reached avr-g++ as part of the argument and gcc treated the flag as a
missing file. The fix covers the whole defect class: USB descriptors
on Leonardo/Micro/MKR, paths under `Program Files (x86)`, and ESP32
`@responsefile` arguments.

New `recipe-exec.ts` module provides the tokenizer, placeholder
substitution and argv exec — pure, testable, no shell involvement.
The ad-hoc tokenizer covers the POSIX subset arduino-cli emits
(single-quoted, double-quoted, mixed segments, response-file tokens);
no new dependency on shell-quote needed. 18 unit tests cover the
parser including the Leonardo recipe shape end-to-end.

`ensureResponseFileStubs` now takes the tokenized argv directly. The
regex extraction was moved to a public `extractResponseFilesFromArgv`
helper so it can be unit-tested without filesystem side effects (the
prior test produced different behaviour on POSIX vs Windows hosts,
depending on whether `C:\...` was treated as a literal directory name
or an absolute path).

The new `precompile-boundary-invariant.test.ts` codifies the
C-linkage boundary the precompile pipeline depends on:

  - The five headers in resources/sources/arduino/ exposed to both
    sides of the precompile/arduino-cli line (arduino_runtime_glue.h,
    openplc.h, Arduino_OpenPLC.h, c_blocks.h, debug.h) must stay free
    of `<Arduino.h>`, `strucpp::`, and strucpp template includes.

  - Every TU compiled by arduino-cli (resources/sources/Baremetal/,
    resources/sources/hal/) must stay free of `strucpp::` and strucpp
    template includes.

108 assertions, pure static text scan, runs deterministically without
mocking a board core. Future regressions of the isolation invariant
fail the CI build instead of relying on human review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves the `src/` → `precompile/sources/` rename from after the archive
step to before the compile step in `handlePrecompileUserLib`. Compile
and archive now read from the stash directory, and the post-archive
move block is removed entirely.

The previous order had a load-bearing side effect with no rollback:
if anything between the first compile and the final rename threw —
archive failure, mid-batch compile failure, IO error — the next run
saw both `src/` populated and the stash empty (or partial), with a
build cache state nothing in the function knew how to reconcile.

Stash-before-compile is idempotent by construction. After a failed
run the stash holds the source, src/ is empty of strucpp output, and
a retry stashes the (now-empty) src/ delta, reads the stash to
discover the TU set, and re-runs the pipeline from there. The
`fs.rename` overwrite semantics handle the case where strucpp emits
a fresh src/ between runs — the src/ version always wins.

Test `stashes sources before compile so a failed archive leaves a
recoverable state for retry` simulates an `avr-ar` failure mid-run
and confirms (a) the stash holds the two strucpp sources with
content intact, (b) `src/` retains only `arduino.cpp` (the HAL stays
where arduino-cli expects it), and (c) a second invocation completes
with the same TU set discovered from the stash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The unbounded `sources.map(async …)` in `handlePrecompileUserLib`
was dispatching one toolchain spawn per TU simultaneously. A 30-TU
strucpp program on a 4-core box used to launch 30 parallel g++
invocations — and on Windows each one drags a cmd.exe shim along,
which the OS can't schedule fairly past the physical-core ceiling.
Long compile times, swap pressure, and occasional `EAGAIN`/spawn
failures on resource-constrained hosts followed.

Introduces `runWithConcurrencyLimit(items, limit, fn)` — the classic
async worker-pool: spawn `min(limit, items.length)` workers that race
for the next index from a shared cursor, preserving input order in
the result array and matching Promise.all rejection semantics.

The new helper is generic and isolated; unit tests cover ordering,
peak-concurrency assertions via an in-flight counter, fail-fast
behaviour on first rejection, and defensive normalisation of limit
≤ 0 / non-integer values (the latter matters because `os.cpus()` can
return 0 in restricted environments like minimal containers).

Integration test `caps concurrent toolchain spawns at the host CPU
count (no unbounded parallel exec)` exercises the wiring end-to-end:
seeds `cpus().length + 4` TUs, instruments the exec mock with a peak
counter, and asserts the peak never exceeds `os.cpus().length` while
parallelism > 1 (the cap is observable rather than degenerate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the legacy `archCandidates.push('unknown')` fallback in
`handlePrecompileUserLib` with an explicit error. When a core's
platform.txt exposes none of `build.mcu`, `build.architecture`, or
`build.arch`, arduino-cli's precompiled-library resolver cannot pick
a subdir to look under, so `libOpenPLCUserLib.a` staged at
`<lib>/src/unknown/` would silently be ignored and the link step
would surface an opaque undefined-symbols error far downstream from
the real cause.

The new error names the FQBN, lists the three properties that were
checked, explains the downstream symptom, and asks the user to file
an issue with the FQBN and the offending core's platform.txt so the
mapping can be added. Affects new/custom/legacy cores only — every
core currently shipped by `com.openplc.arduino` exposes at least one
of the three.

Test asserts the error message format (FQBN, property names,
"file an issue" hint) under a mocked toolchain with all three arch
properties intentionally absent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…aremetal

Restores the wiring between the per-device Modbus configuration UI and
the firmware's MBSERIAL_* / MBTCP_* / MODBUS_ENABLED macros for
arduino-cli baremetal targets. The pipeline existed before commit
c379c7a ("drop communicationConfiguration from device schema"), which
removed it pending Arduino's return as VPP packages. VPP packages have
since shipped (com.openplc.arduino + com.openplc.arduino-industrial +
five other Arduino-family bundles), all sharing
`screens/modbus.json`, but the editor's compile-side consumer was
never reinstated — `vendorScreenData` was being persisted to no
effect for any Arduino board that wasn't the simulator.

New `modbus-defines.ts` is a pure function over the screen's persisted
state (`{ modbus_rtu, modbus_tcp }` after the sibling
openplc-packages fix that unscoped the colliding `persistence` keys).
It emits the exact macro set `resources/sources/Baremetal/ModbusSlave.cpp`
still expects: MBSERIAL_IFACE / MBSERIAL_BAUD / MBSERIAL_SLAVE /
MBSERIAL_TXPIN / MBTCP_MAC / MBTCP_IP / MBTCP_DNS / MBTCP_GATEWAY /
MBTCP_SUBNET / MBTCP_SSID / MBTCP_PWD / MBSERIAL / MBTCP / MBTCP_WIFI
/ MBTCP_ETHERNET / MODBUS_ENABLED. The pre-c379c7a9c emitter's
formatters for IP (dotted → comma-separated) and MAC (colon →
0xnn,…) are reproduced inline.

Defaults are applied per-field when the persisted state lacks the
value. This matters because the VPP form layout (`form-layout.tsx`)
only writes back the field the user touches — toggling "Enable Modbus
RTU" alone yields `{ enabled: true }` with every other field absent,
which would have left MBSERIAL_IFACE / MBSERIAL_BAUD / MBSERIAL_SLAVE
undefined and broken `MBSERIAL_IFACE.begin(MBSERIAL_BAUD)` in
Baremetal.ino. The RTU_DEFAULTS / TCP_DEFAULTS constants mirror the
`default` values declared in `modbus.json` and carry a comment
explaining why they're duplicated in code rather than discovered at
runtime.

The integration in `compiler-module.ts:handleGenerateDefinitionsFile`
routes by `boardRuntime`:

  - `simulator` keeps its hardcoded block (test harness, not a
    deployment target — the user never reconfigures its Modbus).
  - `openplc-compiler` (Runtime v3 / v4) keeps its existing
    `conf/modbus_slave.json` upload-bundle path, not these macros.
  - Anything else (arduino-cli baremetal) reads
    `vendorScreenData["modbus_rtu"]` / `["modbus_tcp"]` and emits the
    helper's output.

16 unit tests cover the matrix: empty state, RTU defaults from the
"only enabled" persistence shape, custom RTU values, RS485 EN pin
gating, TCP Ethernet + static IP, DHCP gating the static block, Wi-Fi
SSID/PWD, optional MAC, RTU+TCP combined (single MODBUS_ENABLED), MAC
and IP escape hatches for pre-formatted literals, and trailing-newline
contract.

Verified end-to-end on Arduino Uno: 414project with modbus_rtu enabled
(Serial, 115200, slave 1), TCP disabled. `defines.h` now contains the
expected six-line block; arduino-cli builds clean (the prior failure
was `MBSERIAL` defined but `MBSERIAL_IFACE` undefined — exactly the
defaults-application bug this commit's helper fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…6/ESP32 etc.)

Two follow-ups to the previous Modbus VPP wiring (97f4956) surfaced
when actually flashing an ESP8266 NodeMCU with Modbus TCP + Wi-Fi:

1. `defines.h` was missing the per-board `BOARD_ESP8266`/`BOARD_ESP32`
   /`BOARD_WIFININA` macro. `handleGenerateDefinitionsFile` was still
   pulling the `define` field from the legacy `hals.json`, which no
   longer carries VPP boards — every Arduino-family board lives in a
   VPP now, so the macro never made it into the file. ModbusSlave.h's
   board-detection chain (`#if defined(BOARD_ESP8266) … #elif defined(
   BOARD_ESP32) … #else #include <WiFi.h>`) then fell through to the
   default branch, where `<WiFi.h>` resolved to WiFiNINA's header on
   any host that has the WiFiNINA library installed. Compile errored
   with `'PinStatus' does not name a type` from inside WiFiNINA.

   Fix: route the lookup through `BoardInfoResolver.resolve()` so VPP
   `hal.define` and legacy `boardEntry.define` both reach defines.h
   the same way every other compile site in this module already does
   (`handleGenerateArduinoCppFile`, `handleCompileArduinoProgram`,
   etc. all use the resolver).

2. The Modbus emitter was treating `MBTCP_MAC`/`MBTCP_IP`/`MBTCP_DNS`
   /`MBTCP_GATEWAY`/`MBTCP_SUBNET` as optional — only emitting when
   the corresponding screen field had a value. But `Baremetal.ino`
   references all five unconditionally inside `#ifdef MBTCP`:

       uint8_t mac[]     = { MBTCP_MAC };
       uint8_t ip[]      = { MBTCP_IP };
       uint8_t dns[]     = { MBTCP_DNS };
       uint8_t gateway[] = { MBTCP_GATEWAY };
       uint8_t subnet[]  = { MBTCP_SUBNET };

   and uses `sizeof(arr) < 4` as the compile-time DHCP-vs-static
   selector that cascades through to `mbconfig_ethernet_iface(mac,
   …, NULL, NULL, …)`. Omitting a single macro broke the build with
   "not declared in this scope". This was a latent contract violation
   the old `communicationConfiguration` schema also tripped (its
   emitter had the same `if (modbusTCP.tcpMacAddress !== null)`
   gating) — it just never surfaced because the historical UI always
   required those fields.

   Fix: always emit the five MBTCP_* macros when TCP is enabled.
   Missing values lower to a single-byte `0` so the resulting array
   has `sizeof == 1`, the cascade's `< 4` test fires, and runtime
   takes the DHCP/NULL path. The Wi-Fi branch inside
   `mbconfig_ethernet_iface` ignores the IP/gateway/subnet args
   entirely on ESP8266/ESP32 (see `ModbusSlave.cpp:199-225`), so the
   placeholders are harmless there too.

End-to-end verified on Arduino Uno (Modbus RTU) and ESP8266 NodeMCU
(Modbus TCP + Wi-Fi): both compile clean and the device boots with
the configured transports active. (Static-IP-reachability over Wi-Fi
is a separate, pre-existing runtime concern — the `WiFi.config(…)
→ WiFi.begin(…)` sequence in `mbconfig_ethernet_iface` is byte-
identical to its 2022 original and outside the scope of this commit.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pes in form layout

The VPP screen schema (`schema/screen.schema.json`) declares 13 field
types, but the form layout renderer only had explicit branches for
`boolean`, `number`, and `select` — everything else fell through to
a plain `<input type='text'>`. That made the Wi-Fi password on the
Modbus screen show in cleartext while the user typed it, and the
IP / DNS / gateway / subnet / MAC fields accept any string with zero
format hint.

Adds three new branches:

  - `password` → `<input type='password' autoComplete='new-password'>`
    Native browser masking. Suppresses credential autofill since this
    is a device config form, not a sign-in.

  - `ip-address` → `<input type='text' inputMode='decimal' pattern=…>`
    Default IPv4 pattern + 15-char cap; both overridable per-field
    via the schema's `validation` and `maxLength`. The `placeholder`
    declared on the modbus.json fields (`192.168.0.10`, `8.8.8.8`,
    etc.) now actually shows up. `inputMode='decimal'` hints mobile
    keyboards to numeric layout while keeping `.` typeable.

  - `mac-address` → same shape with the colon-separated hex pattern
    and the screen's `DE:AD:BE:EF:00:01` placeholder.

The generic text fallback now also honors `placeholder` /
`maxLength` / `validation` from the schema so plain `type: "text"`
fields (Wi-Fi SSID, RS485 EN pin) pick up their hints too.

`FieldDef` is extended with the three optional props and the input
className gets factored into `TEXT_INPUT_CLASS` so the next field
type that lands doesn't drift style-wise. No behaviour change for
boolean / number / select / unknown types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… on top

The device dropdown in the board Configuration screen sorted every
entry alphabetically into one flat list — so a user with both
`com.openplc.arduino` and `com.openplc.espressif` installed saw
Arduino boards interleaved with ESP boards by name. Hard to scan,
and the built-in Runtime v3 / Runtime v4 / Simulator entries could
end up scattered too depending on which packages were installed.

New ordering, owned by the pure `orderBoardsByVppGroup` helper:

  1. Built-in targets first — anything in the merged Map without a
     `vpp` field — sorted alphabetically (which incidentally yields
     Runtime v3 → Runtime v4 → Simulator naturally).
  2. VPP-sourced devices, partitioned by `info.vpp.packageId`. Groups
     sorted alphabetically by package id; devices within each group
     sorted alphabetically by display name. Every board from
     `com.openplc.arduino` lands contiguously before any board from
     `com.openplc.espressif`, etc.

`hardware-module.ts:getAvailableBoards` swaps its final
`.sort(localeCompare)` pass for the helper. No other call sites
change — Map insertion order propagates through `Array.from(.entries
())` in `board.tsx` so the dropdown picks up the new ordering for free.

Helper is pure, no I/O, no module dependencies; defensive against
empty input and malformed manifests (entries with falsy `packageId`
fall back to the built-ins bucket). Nine unit tests cover the full
contract including a reproduction of the user-reported scenario
(arduino + espressif installed side-by-side).

The `AvailableBoards` Map's value type is declared inline in
`types.ts` and the legacy `BoardInfo` export refers to the hals.json
schema instead — derive the runtime/UI value type via `infer V` on
the Map so future shape drift propagates here automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

Pull request was closed or merged during review

Walkthrough

Adds unified board resolver, Arduino precompile and library staging, VPP catalog with signature verification, capability-driven UI, per-board pin mapping, debug-spec resolution, IPC/adapters updates, and extensive tests.

Changes

End-to-end VPP, compile, and UI integration

Layer / File(s) Summary
Unified implementation and adaptations
...
Implements resolver-driven compile flow, Arduino precompile/library install semantics, VPP catalog + signature verification, per-board pins, capability-gated UI, IPC/adapters wiring, and corresponding store/tests.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

feature, enhancement

Suggested reviewers

  • JoaoGSP
  • dcoutinho1328

Poem

A rabbit compiles beneath moon-bright logs,
Precompiling crumbs for Arduino dogs;
Vendors and pins now dance by board,
Signed packs hop in through a trusted ward.
With specs that debug and catalogs new,
I twitch my nose—ship it, we’re through! 🐇✨

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/vpp-compile-pipeline-port

marconetsf and others added 13 commits May 28, 2026 22:52
Adds a Browse Catalog tab next to Installed in the Package Manager,
modelled after the Arduino IDE Board Manager. Each card folds every
package action into a single chevron-trigger menu (Radix DropdownMenu)
that drives install / update / switch / uninstall / editor-incompat
states off a small state machine derived from installed version vs the
package's per-version minEditorVersion.

PackagePort gains listRemoteCatalog() and installFromRemote() so the
Browse Catalog UI flows through the existing port abstraction. Both
adapter methods are stubs today — listRemoteCatalog rejects with a
clear "backend not yet available" so the CatalogBrowser surfaces its
error banner, and installFromRemote resolves with a backend-not-wired
error that names the requested packageId@version. The wire contract
the CDN backend must match is documented in EDGE-482 (and its two
subtasks); the editor adapter swap is a strict string replace of the
stubs with HTTP fetch + write-to-disk handoff to the local install
pipeline once the CDN ships.

Adds a tiny inline semver utility (compare + compatibility check) to
avoid pulling in the semver npm package for two comparisons, with 100%
test coverage as required by the frontend utils threshold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… flag

Extracts the canned catalog + the two port-shaped mock functions
(mockListRemoteCatalog, mockInstallFromRemote) into a dedicated
remote-catalog-mock.ts so the adapter no longer carries ~200 lines of
fixture data. The adapter gains a USE_LOCAL_MOCK constant (committed
value MUST stay false) that delegates to the mock when flipped — a
working-tree-only edit that lets devs exercise the Browse Catalog UI
end-to-end against the fixture while EDGE-482 (real CDN) is still
pending.

Adds full coverage for the mock module to keep the 100% threshold on
src/middleware/adapters/editor/ intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swaps the listRemoteCatalog / installFromRemote stubs in the editor
adapter for real calls against the autonomy-edge VPP catalog API.
CATALOG_BASE_URL defaults to the staging deployment
(https://api-staging.autonomylogic.com); local backend devs flip to
http://localhost:3333. The USE_LOCAL_MOCK working-tree flag is kept for
offline dev against the canned fixture in remote-catalog-mock.ts.

listRemoteCatalog now fetches GET /vpp-catalog/v1/catalog.json directly
from the renderer — JSON shape is the contract documented in EDGE-482.

installFromRemote defers to a new IPC channel packages:install-from-url:
main downloads the .vpp binary from the catalog's downloadUrl, writes
it to {tmp}/openplc-vpp-{id}-{ver}-{uuid}.vpp, hands the path to the
existing PackageManagerModule.importFromFile pipeline (same code the
"Add from file..." flow already exercises), cleans up the temp file in
finally, and emits packages:boards-updated on success. The download
must run in main because the catalog backend serves a private S3
bucket through its own API — the renderer is not authorized to talk to
S3 directly and lacks ergonomic write access to {userData}/packages
anyway.

Port signature gains downloadUrl as a required argument so the editor
never constructs URLs on its own; the catalog entry is the source of
truth per the backend contract. CatalogBrowser propagates downloadUrl
from the selected RemoteVersionEntry through the action menu.

Tests cover the success path (fetch + JSON parse, IPC delegation with
the full payload), the error paths (non-ok HTTP status, fetch
rejection, bridge-side install failure), and keep the mock module at
100% coverage with the updated 3-arg signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…de finds Arduino.h

handlePrecompileUserLib was substituting the recipe's {includes} placeholder
with only the project-local paths -I${srcDir} / -I${baremetalDir}, dropping
the core and variant include paths that arduino-cli normally injects there
at compile time. Most TUs in the precompile (arduino_runtime_glue.cpp,
pou_MAIN.cpp, configuration.cpp etc.) never directly #include <Arduino.h>,
so they compiled fine — but c_blocks_code.cpp (emitted whenever the project
has a C/C++ POU) does, and on cores whose platform.txt does not embed
-I{build.core.path} literally into recipe.cpp.o.pattern (Renesas Uno R4
WiFi is the case that surfaced it), the precompile blew up with:

  c_blocks_code.cpp:5:10: fatal error: Arduino.h: No such file or directory

Pull build.core.path and build.variant.path from
extractToolchainProperties (already populated from arduino-cli
--show-properties=expanded) and prepend them to the includeArgs list.
build.core.path is mandatory — without it Arduino.h would never resolve
and no compile could succeed; raise an actionable error if --show-
properties omits it. build.variant.path is optional (some runtime-only
or minimalist cores omit variants); skip the -I flag when empty.

Ordering mirrors arduino-cli's own injection (core, variant, then
project-local paths) so headers in the project src/ tree never shadow
core/variant ones by accident.

Adds three regression tests in __tests__/handle-precompile-user-lib.
test.ts:

  - argv carries -I${build.core.path} and -I${build.variant.path} when
    both are populated.
  - the variant -I is omitted (but the core -I stays) when
    build.variant.path is empty.
  - hard-fail with an actionable error when build.core.path is absent.

Note: src/backend/editor/compiler/compiler-module.spec.ts is also
updated (cannedProps gains build.core.path / build.variant.path, and
the same three tests are added there) but jest's current testMatch
config does not pick up .spec.ts files outside __tests__/ — that file
has not been running. The defensive update keeps it correct for the
day someone fixes the matcher, but the tests above in
__tests__/handle-precompile-user-lib.test.ts are the ones actually
guarding the regression today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Arduino.h defines `min` and `max` as preprocessor macros, which
collide with the `std::min` / `std::max` function templates and
`numeric_limits<T>::min()` / `max()` static members declared by
<algorithm> / <limits> (both pulled in transitively via
iec_string.hpp). Without scrubbing, projects with a C/C++ POU
fail to build with "macro min requires 2 arguments, but only 1
given" cascades across the entire AVR libstdc++ tree.

Undef both macros immediately after `#include <Arduino.h>` and
before the strucpp runtime headers.

Back-port of 6a5fbf6 (already on origin/development via #794) —
this branch (feat/vpp-compile-pipeline-port) diverged at b181234
before that fix landed, so AVR projects with a C/C++ POU were still
broken here. The previous commit on this branch (which makes the
precompile actually find Arduino.h via the -I{core,variant} fix) is
what surfaces this collision in the first place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets a runtime-v4 board expose physical GPIO through the existing pin-mapping
table instead of a bespoke vendor screen:

- Forward a manifest-declared `capabilities` block from VPP boards into
  BoardInfo so a GPIO board can opt into `pinMapping` (resolveTargetCapabilities
  already merges it over the preset).
- Render the Pin Mapping table for any non-simulator target with the
  pinMapping capability (not only Arduino), alongside runtime stats when
  connected.
- Emit a `pins[]` array in the generated plugin config from
  devices/pin-mapping.json: digital in/out as {pin,direction,byte,bit},
  analog out as PWM {pin,direction:'pwm',word}; analog in is skipped.
- Fix ZIP entry separators to forward slashes so nested files (e.g.
  vpp_plugin/) extract correctly on POSIX runtimes — path.join emitted
  backslashes on Windows, which the runtime treated as literal filenames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reject unsigned or tampered packages at the single import trust boundary (both local "Add from file…" and remote install converge on importFromFile).

- verify-package-signature.ts (backend/shared): pure verifier — checks alg/keyId, the Ed25519 signature over the canonical payload, and that every on-disk file matches the signed sha256 map exactly (no missing/extra/altered files). Fails closed. 100% test coverage.
- trusted-keys.ts: keyId -> embedded public key registry (ready for rotation).
- package-manager-module.ts: call the verifier after schema validation and before any path use; REQUIRE_SIGNATURE flag (committed true) gates strict enforcement.

Mirrors the signing side in openplc-packages byte-for-byte (canonicalization, hashing, file enumeration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Package import and uninstall failures were silently swallowed — a rejected package (now including signature verification failures) closed the popover with no feedback. Show the backend error via the existing debugger-message error modal, mirroring the library manager. Import suppresses the modal when result.canceled is set (user dismissed the file picker).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Format verify-package-signature.ts and its test to satisfy the shared ci-format check (and keep the backend/shared surface byte-identical with the upcoming openplc-web mirror).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
trusted-keys.ts is a platform-agnostic trust anchor (keyId -> public key). Moving it to backend/shared/utils/vpp puts it on the byte-identical shared surface, so the editor and openplc-web are guaranteed (via ci-sync) to trust the same signing keys. package-manager-module.ts stays editor-only — it is an Electron adapter (app.getPath, local fs, registry) and the web will provide its own under backend/web, reusing the shared verifier + trusted keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the feat/vpp-compile-pipeline-port branch onto current
development.  Auto-merging covers ~50 files (Baremetal sources,
package-manager adapters, device store, catalog browser UI,
hardware-module updates).  The compiler-module conflict resolution
is the load-bearing change: dev refactored compiler-module into the
shared `runCompilePipeline` while vpp built VPP support on the
pre-refactor shape.

Architectural call (per agreed plan): everything VPP-related lives
in shared so a future VPP-on-web rollout has a single canonical
implementation to extend.

Shared zone changes
-------------------

- `backend/shared/hardware/board-info-resolver.ts` (new):
  BoardInfoResolver class with `BoardInfoResolverConfig` —
  platform injects `halsContent`, `packageManager` (no-op stub on
  web), `resolveHalSourcePath`, `resolvePackageRelativePath`.
  Returns a uniform `BoardBuildInfo` shape regardless of whether
  the board comes from hals.json or a VPP manifest, plus the
  classification flags (`isSimulator` / `isRuntimeV3` /
  `isRuntimeV4`) the pipeline branches on.  Synchronous API.
- `backend/shared/compile/steps/modbus-defines.ts` (moved from
  editor): emit `MBSERIAL_*` / `MBTCP_*` macros from VPP screen
  state.  Pure function; full test matrix moved alongside.
- `backend/shared/compile/steps/generate-defines.ts`: optional
  `vppModbusState` input; when set on non-simulator/non-v4
  targets the comms block comes from `generateModbusDefines`
  instead of being skipped.
- `backend/shared/compile/pipeline.ts`: `vppModbusState` threaded
  through `RunCompilePipelineArgs` into `generateDefinesContent`.
- `backend/shared/compile/steps/resolve-board-selection.ts`
  DELETED: superseded by `BoardInfoResolver` (which now also
  carries the runtime classification flags).  Same for the test.

Editor wiring
-------------

- `compiler-module.ts`:
  - `#createBoardInfoResolver()` helper builds a resolver wired
    with `readHalsFile()` + `PackageManagerModule` + filesystem-
    backed path helpers.  All callsites now reach for this.
  - `handleGenerateDefinitionsFile` uses the resolver for board
    defines AND reads `vendorScreenData.modbus_rtu/_tcp` from
    `devices/configuration.json` to populate `vppModbusState`,
    then hands both to the shared `generateDefinesContent`.
  - `handleGenerateArduinoCppFile` sources `halSourceFile` from
    the resolver (covers VPP-installed boards).
  - `compileProgram` board-selection: single resolver call
    replaces the dev-side `resolveBoardSelection` + inline VPP
    fallback AND the vpp-side lazy `getResolvedBoardInfo`.
    `BoardBuildInfo` → `BoardHalsBuildEntry` adaptation feeds
    the pipeline.  Simulator hex-path derivation uses
    `boardEntry.platform` (populated for VPP too).
  - Dropped the vpp-side inline upload block at the end of
    `compileProgram` — `runCompilePipeline` handles uploads
    via `platformPort.uploadArduinoBoard` (compileOnly-aware).
  - Dropped `#getBoardRuntime` (replaced by `resolver.resolve(...)
    .boardRuntime` in the debug-compile path).
- `hardware-module.ts`: `getBoardBuildInfo()` rewired to the
  shared resolver with the editor's filesystem adapters.
- `editor/hardware/board-info-resolver.ts` and its test DELETED
  (moved to shared).

Verification
------------

- npx tsc --noEmit: clean
- npx jest (full): 4652 pass / 3 skip; 2 pre-existing failures on
  dev (editor-compiler-platform-port + use-runtime-polling) — not
  introduced by this merge.
- npx eslint: 0 errors (236 pre-existing warnings)
- npx prettier --check: clean
- npm run validate:arch: passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thiagoralves and others added 15 commits June 3, 2026 11:00
The HAL Settings screen's "Enable Bus Fault Detection" toggle label
was being cropped: the form-layout's Label used a fixed `w-32`
(128px) box with `whitespace-nowrap`, so any label wider than the
gutter overflowed into the toggle/control sitting at `gap-4`
distance — the right edge of the text disappeared underneath the
neighbouring widget.

Swapped `w-32` for `min-w-32` on both label sites (the boolean
branch and the non-boolean branch) so short labels still align at
the 128 px gutter the screen layouts assume, but long labels grow
horizontally to fit instead of clipping. The change is screen-wide
because every form-layout field uses this same Label class —
matches the existing convention now to fit ANY label, not just
the short ones the original sizing happened to accommodate.

Also adds a regression test in `generate-vendor-plugin-config.test.ts`
pinning the wiring contract for the toggle that prompted this
investigation: `vendorScreenData['hal-config'].fault_detection_enabled
= false` MUST flow through the generator to the emitted JSON as
`"fault_detection_enabled":false` (verbatim), not get dropped by a
truthiness check. Test passes against the existing generator; this
locks the contract so a future tightening of the falsy-skip logic
in `Object.assign(result, value)` doesn't silently regress the
toggle's wire path to the runtime plugin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The vendor-screen save flow had a race: `setVendorScreenData` set
`deviceUpdated.updated = true` on the store, and a `useEffect` on
the `DeviceEditor` component THEN called
`handleFileAndWorkspaceSavedState('Configuration')` one render
later to flip `editingState` to 'unsaved'. The upload flow gates
on `editingState === 'unsaved'` before saving — so a quick
sequence of "change dropdown, click Upload" (or any change made
while the device editor wasn't mounted) skipped the save, and
the compile-pipeline read stale `devices/configuration.json`
from disk.

User-visible bug: changing the SLM-RP4 HAL Settings fault-action
dropdown from "Log warning only" to "Zero outputs and stop PLC"
and uploading kept the runtime running with `fault_action=log_only`
because the new value never reached disk.

Fix: `setVendorScreenData` now calls
`sharedWorkspaceActions.handleFileAndWorkspaceSavedState('Configuration')`
directly, right after the store mutation. The dirty-flag flip is
concurrent with the mutation, so the upload gate sees the change
no matter how fast the user clicks. The `DeviceEditor`'s useEffect
becomes redundant (both paths now do the same thing) but is left
in place — it's the fallback for any code path that bypasses the
action (none today, but the symmetry is cheap).

The call is optional-chained because slice tests compose a subset
of the store (device + project + console + editor + library)
without the shared slice. The store mutation is the load-bearing
part; the dirty-flag side effect silently no-ops when shared isn't
in the root, keeping those tests hermetic.

Regression test in `shared-slice.test.ts` (which composes the full
store including shared) pins the contract: after
`setVendorScreenData`, both `Configuration.saved === false` and
`workspace.editingState === 'unsaved'` are true synchronously,
with no awaits or re-renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ion dirty markers

Reframes the previous vendor-screen-specific dirty-flag fix
(c726fed) into the cleaner architectural fix: the build button
now ALWAYS saves the full project before kicking off the compile,
regardless of `editingState`. Same for the debugger start. This
mirrors what the library-build path was already doing — see the
comment block at ~line 285 in this file.

Why the gate was wrong
----------------------
The build/debug handlers used to gate on
`editingState === 'unsaved'`, which is an effect-driven workspace-
level boolean derived from each editor's individual dirty flag.
That summary lags real store mutations by at least one render
(useEffect tick) and only flips when the relevant editor is
mounted. Vendor-screen edits, Monaco buffers, ladder/FBD node
edits, the manifest editor — each has its own dirty-tracking
path with its own race-against-the-build-button. Plugins added
later would have to remember to wire into the same flag too.

Always-save makes that whole class of races go away: the compile
pipeline reads source from disk (`project.json`, `devices/*.json`,
`pous/**`, …), so flushing the store to disk right before the
compile reads from it is the load-bearing invariant. Cost is a
few JSON.stringify + file writes; save is idempotent when nothing
changed, and the library-build path has been doing this every
build for a while without complaint.

Reverts
-------
The previous commit's c726fed per-action change to
`setVendorScreenData` (synchronously calling
`handleFileAndWorkspaceSavedState`) is removed along with its
regression test. With the build-always-saves invariant in place,
that per-action flush is treating a symptom at the wrong layer —
and forcing the device slice to know about the shared slice's
workspace handler was adding cross-slice coupling that pays no
dividend now.

The `DeviceEditor`'s useEffect that flips `editingState` on
`deviceUpdated` change stays — it still drives the title-bar
"unsaved" indicator and Save shortcut affordances. Just the
build's reliance on it is gone.

Read-only projects still skip the save (Monaco/graphical editors
are locked, nothing to flush; gate keeps a stray dirty flag from
403-ing the build).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Post-merge fixup: the vpp-package-signing branch added
`openModal('debugger-message', ...)` calls in `handleImportFromFile`
and `handleUninstall` (error surfaces for failed signature
verification / uninstall) but didn't bind `openModal` to the
component — the merge with feat/vpp-compile-pipeline-port broke
typecheck with `Cannot find name 'openModal'`. Same pattern the
sibling `catalog-browser.tsx` uses: `useOpenPLCStore((s) =>
s.modalActions.openModal)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… project)

Adds a hover-revealed 3-dot menu to every card in the start screen's
recent-projects grid, with two actions:

  - **Remove from list** — drops the entry from `projects.json`
    immediately, no confirmation, disk untouched. Re-opening the
    project by path later re-adds it to recents.
  - **Delete project** — surfaces a destructive-action confirmation
    modal (`confirm-delete-project`) showing the project name + full
    path + an explicit "cannot be undone" line. On confirm:
    recursively rm's the project directory AND drops it from
    `projects.json`. On cancel: no-op.

Backend
-------
`ProjectService.deleteProject(projectPath)` (new) gates the
recursive `fs.rm` on the directory actually containing a top-level
`project.json` — without it, a stale history entry pointing at
e.g. `/Users/foo/Documents` would let the renderer wipe that
directory by passing its path. The safety check matches what
"is this an OpenPLC project root" actually means.

Stale entries (project.json missing) get evicted from the recent
list without touching disk — the entry was already pointing at
deleted data; keeping it serves no one. Other fs errors
(permission denied, busy) leave the entry in place so the user can
retry after fixing the cause.

Plumbing
--------
- IPC handlers `project:remove-from-recent` + `project:delete`
  registered on `MainProcessBridge`.
- Preload exposes `window.bridge.removeProjectFromRecent` +
  `window.bridge.deleteProject`.
- `ProjectPort` gains two methods (`removeRecentProject`,
  `deleteProject`); editor adapter wires them to `window.bridge`.
  Web adapter doesn't exist in this repo yet — the port contract
  is the canonical surface, web's stubs land when the web repo
  catches up.
- New `ConfirmDeleteProjectModal` (`'confirm-delete-project'` modal
  type) mounted in the app-layout next to the existing
  `ConfirmDeleteElementModal`. Distinct from the per-POU /
  per-datatype delete confirmation because the data shape is
  different (raw `{ projectName, projectPath }` off the recents
  list rather than a live store element) and the blast radius is
  much bigger (`rm -rf` on a directory vs deleting one file).
- `DisplayRecentProjects` wraps each `File` card in a `group
  relative` container with an absolute-positioned Radix
  DropdownMenu trigger. Click on the 3-dot button stops
  propagation so the menu opens without also triggering the
  card's onClick (which opens the project).

After either action, the recents list is re-read from
`projects.json` via `getRecentProjects()` and fed into the
`setRecent` store action. Re-reading rather than mutating
client-side keeps the renderer's view of the list aligned with
whatever the service actually wrote (covers the stale-entry
auto-eviction path among other things).

Regression tests in `project-adapter.test.ts` (4 new) pin the
adapter's pass-through behaviour for both methods, including the
safety-gate failure shape so a future bridge refactor doesn't
accidentally swallow the error message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two UX tweaks on the recent-project card's overflow menu:

  - `top-2` → `top-10`: the button used to sit on the tab portion
    of the folder shape (the small lip above the body). Moved it
    down ~32px so it lands on the blue body — the folder SVG's
    body starts at y≈33 inside the 160px card, so top-10 (40px)
    sits just inside it.
  - Dropped `opacity-0` + `group-hover:opacity-100` + the
    transition: the menu is now always visible. Discoverability
    over minimalism — users shouldn't have to hover to find out
    the menu exists.

Cosmetic only; the menu's behaviour and the two actions
("Remove from list", "Delete project") are unchanged.
CI's Format Check ran `npx prettier --check './src/**/*.{ts,tsx}'`
and flagged 5 files that had drifted from the project's prettier
config (120-col width, no semicolons, single quotes, trailing
commas). All five were touched by recent commits in this branch:

  - compiler-module.ts (precompile -I order fix)
  - parse-project-files.test.ts (per-target pin-mapping migration
    test additions)
  - module-slots-layout.tsx (per-target pin scoping)
  - device-slice.test.ts (per-target pin scoping regression tests)
  - project/slice.ts (per-target pin scoping pool builders)

Net change is 17 insertions / 19 deletions — purely whitespace and
line-folding, no logic touched. `npx prettier --check './src/**/*.
{ts,tsx}'` now reports "All matched files use Prettier code style!"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two CI debts that were inherited via the development merge:

  1. `use-runtime-polling.test.ts` was authored against Vitest's
     `vi.hoisted` + `vi.mock` and fails to compile on Jest (TS2339
     "Property 'hoisted' does not exist on type 'typeof jest'").
     Rewritten using Jest's babel-plugin-jest-hoist exception:
     `mock*`-prefixed locals get hoisted into the `jest.mock`
     factories the same way Vitest's `vi.hoisted` would. All 4
     suites pass.

  2. `editor-compiler-platform-port.test.ts` failed type-check on
     the two `xml2stArgs`-forwarding cases because the test's
     `jest.fn(async () => undefined)` couldn't match the strict
     `Promise<MethodsResult<string | Buffer>>` shape introduced by
     the recent handler signature change. Typed jest.fn explicitly
     via `jest.fn<ReturnType<...>, Parameters<...>>` so the call-
     tuple indexing (`callArgs[2]`) and resolved-value type stay in
     lockstep with the handler type.

Coverage gap closure (hybrid strategy — only files touched by this
PR's commits; pre-existing 0%-coverage debt on dev is filed for
follow-up):

  - `generate-defines.ts` — 3 new tests for the VPP Modbus block
    branch (the arduino-cli path that emits MBSERIAL/MBTCP defines
    from `vppModbusState`)
  - `board-info-resolver.ts` — 6 new tests (partial compilerFlags
    combinations, optional core/platform/source omission, debug
    spec propagation on both hals + VPP paths)
  - `debug-spec.ts` — 5 new tests (number coerce of non-finite,
    boolean / string coercion, fallback copy on missing
    noneEnabled/pickProtocol message blocks, prompts without
    cacheKey)
  - `device/slice.ts` — 4 new tests for `restoreVendorScreenSlice`
    (key-present, key-absent-deletes, ownedKeys scope isolation,
    fresh-store initialization)
  - `project/slice.ts` — 5 new tests: `updateLibraryManifest`
    setter; `updateEthercatConfig` failure shapes (no
    remoteDevices / device-not-found / not-ethercat) and happy
    path. Fixed the pre-existing "buffer matches serialized
    variables" test that was hardcoding a wrong indentation
    template — now uses the real serializer via
    `generateIecVariablesToString`.

Istanbul ignore markers added to genuinely-defensive branches that
guard schema-drift or working-tree-only dev toggles:

  - `package-adapter.ts` USE_LOCAL_MOCK = false branches (the
    constant is committed-false and the comment says it MUST stay
    that way for the catalog UI)
  - `parse-project-files.ts` unknown-language body fallback,
    fallback-also-failed branch, schema-required resource fields
  - `generate-vendor-plugin-config.ts` malformed-address guards
    (callers always pass io-mapping-validated addresses)
  - `project/slice.ts` pouName-undefined guards on
    reconcile/regenerateVariablesText (callers gate on local
    scope), `code` non-string guard (TS-guaranteed by union),
    `pou.interface?.variables` defensive in syncVariableAliases

These are paired with the explanatory `-- reason` comment Istanbul
expects, so future readers see why the branch is excluded rather
than what's missing.

All tests pass (4814 passed, 3 skipped). `npm run lint` and
`npm run validate:arch` clean. Branch is up-to-date with
`origin/development` via the merge in `a5470e8df`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The editor was doing the board lookup + runtime-flag derivation +
BoardBuildInfo → BoardHalsBuildEntry adapt inline at the top of
`compileProgram` — ~30 lines that the shared `runCompilePipeline`
expects pre-derived.  Web had already extracted the same logic into
`backend/shared/compile/steps/resolve-board-selection.ts` (in a
hals-only flavor), so the two repos diverged on a piece of code
that's supposed to be byte-identical.

Centralised on editor's validated flow (BoardInfoResolver-backed,
covers hals.json + installed VPP packages) so a future tweak (new
runtime kind, new VPP capability) lands once and editor + web
compile entrypoints stay in lockstep.  Web's existing hals-only
version will be replaced by this one in the paired web PR; web's
no-op packageManager means the same code naturally degrades to
hals-only there until the VPP catalog ships on web.

Editor's `compiler-module.ts` shrinks from the inline lookup-and-
adapt to one helper call.  The `boardInfo.halSourceFile` read later
in the compile path stays inline (calls `resolver.resolve` again);
threading the full BoardBuildInfo through the selection result
would have widened its surface area beyond what the pipeline
actually branches on.

Tests: 8 unit tests covering simulator / runtime-v3 / runtime-v4 /
arduino-cli classification, partial / complete compilerFlags
adaptation, max_data_size pass-through, and VPP-package resolution
through the same shape.  All 350 compile-suite tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The per-target pin-mapping refactor (81220fc) changed
`devices/pin-mapping.json` from a flat `DevicePin[]` to a
per-board dict `{ [boardName]: DevicePin[] }`, but the compile
path was still casting the parsed JSON to `DevicePin[]` and
handing it to `generateDefinesContent`, which calls `.filter`
on it.  Real-world projects saved after the refactor crash
during simulator / Arduino builds with:

    TypeError: devicePinMapping.filter is not a function
        at generateDefinesContent

Two read sites needed updating in `compiler-module.ts`:

  1. The main compile path (`compileProgram` → pipeline args).
     Now reads `DevicePin[] | Record<string, DevicePin[]>`,
     indexes the dict by `boardTarget`, falls back to legacy
     flat-array projects, and `[]` for missing/empty.

  2. The VPP packaging path (`packageVendorPlugin` → plugin
     config's `pins[]`).  Pre-refactor it only handled the
     array branch via `Array.isArray`, which meant new
     projects fed the packager an empty pin list and the
     generated plugin config came out with no GPIO mappings.

Both sites now match the union shape declared by
`pinMappingFileSchema` (z.union of dict + legacy array) in
`backend/shared/types/PLC/devices/pin.ts`.

The shared `generateDefinesContent` itself doesn't change —
it's the right consumer; the editor caller was the layer that
forgot to extract the active board's pins.

Also adds a module-local `declare const APP_VERSION: string`
inside `catalog-browser.tsx` so the file resolves on web too
(where `src/globals.d.ts` doesn't exist).  Same effective
type as editor's top-level declaration; TS merges identical
`declare const` declarations so editor is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…le-pipeline-port

# Conflicts:
#	src/frontend/components/_organisms/workspace-activity-bar/default.tsx
Two follow-ups from the development merge (#840 — "make public
projects editable; gate only backend writes"):

1. Resolve the build-save conflict in `workspace-activity-bar`.
   Dev added a `canEdit` capability so viewers of a public project
   (no write permission) can compile locally without their save
   getting 401'd by the backend.  Our branch had already removed
   the lossy `editingState` gate so the build always saves.  Merged
   pattern keeps the always-save behavior but gates it on
   `canEdit` instead of `!isReadOnly` — the previous `isReadOnly`
   flag was removed in dev with the now-deleted `ReadOnlyProjectModal`.
   Same gate applied to the debug-start path (mirrors the build).

2. Add `notify-no-write-permission.test.ts` to jest's
   `testPathIgnorePatterns`.  Dev authored it as a Vitest-native
   test (top-level `vi.mock` with relative path, expecting the
   runner to hoist).  Vitest does hoist `vi.mock` natively; Jest's
   `babel-plugin-jest-hoist` only recognises literal `jest.mock`
   and the test fails to load on editor.  Mirror of the same
   asymmetric-runner pattern web already uses for 6 jest-only
   tests in `vitest.config.ts` (`python-lsp`, library-build-
   orchestrator, pipeline, generate-confs, etc.).

All 4822 editor tests pass, arch + format + lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Library Manager and Package (Board) Manager popovers both had an
"Add from the internet..." entry sibling to "Add from file...".
"Catalog" is the noun the CDN actually exposes (the package /
library manager tabs in the UI both use "Catalog" as the section
name), and the renamed entry reads cleaner — "from the internet"
was vague about what's being downloaded.

Updated:
  - frontend/components/_features/[workspace]/editor/package-
    manager/index.tsx  (VPP / Board Manager)
  - frontend/components/_features/[workspace]/editor/library-
    manager/system-libraries-tab.tsx  (Library Manager)
  - middleware/shared/ports/library-port.ts  (comment reference)
  - frontend/components/_organisms/modals/missing-libraries-
    modal.tsx  (comment reference)

Shared zone byte-identity preserved — both editor and web ship
the same labels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thiagoralves thiagoralves changed the title feat(compile): port build pipeline onto VPP target metadata feat: VPP compile pipeline + signing + debug resolver + per-target pin mapping Jun 4, 2026
thiagoralves and others added 4 commits June 4, 2026 15:58
Editor-side support for the Arduino Opta v0.2/0.3 VPP package:

- vpp_config.h: pure emitter step that converts vendorScreenData into
  C preprocessor #defines for arduino-cli targets that opt into
  vppIo. Object-arrays also emit a _FOREACH(X) helper macro the
  driver can use to unroll per-index defines into a struct-literal
  array. Placeholder stub shipped in the firmware skeleton so HAL
  files can #include "vpp_config.h" unconditionally.

- Module-slots layout: surfaces a per-row "Mode" select on the IO
  Mapping table when the channel was resolved out of the resolver's
  new perChannelChoices entry. Flipping the mode writes back to
  slotsConfig[slot][modeFieldId], the address allocator re-runs, and
  the IEC address re-allocates. Adds the fixed-module concept: a
  module flagged `fixed: true` auto-pins to slot 1, hides from the
  picker, can't be removed/replaced, and stays put through Clear All.

- Capability plumbing: forwards `compiler` / `vpp` / `capabilities`
  from BoardInfoResolver through resolveBoardSelection into the
  pipeline's BoardHalsBuildEntry. Without this, vppIo collapsed to
  false and the emitter never fired for VPP arduino-cli boards.

- Per-board arduino libraries: BoardBuildInfo.extraArduinoLibraries
  now flows through to handleLibraryInstallation, which merges them
  with GLOBAL_LIBRARIES. Install failures are warnings (the user may
  have the library installed elsewhere) so the build continues and
  arduino-cli compile is the source of truth for missing headers.
  Drops the hardcoded P1AM placeholder.

- Build button: upload gate keys off `directUsbUpload` capability
  instead of the legacy `isArduinoTarget` predicate (which read
  `pinMapping` — Opta sets that false). Upload now enabled for every
  arduino-cli target including VPP-IO boards.

- Arduino runtime: openplc.h gains MAX_REAL_INPUT / MAX_REAL_OUTPUT
  and IEC_REAL **real_input / **real_output (non-AVR only).
  Baremetal.ino allocates them. arduino_runtime_glue binds
  LocatedSize::DWord on Input/Output to these buffers — OpenPLC
  convention treats %ID / %QD as REAL on arduino-cli. Lets drivers
  deliver engineering-unit readings instead of raw ADC counts.

250 tests pass across the pipeline + resolver + capability + adapter
suites. tsc -b clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-scroll

Three small bugs reported against the new VPP UI:

1. Package Manager: selected row had no visual indication.  Mirrored
   the Library Manager's selected styling — `bg-brand/20` background,
   inset 3-px left border (`shadow-[inset_3px_0_0_var(--primary-default)]`),
   bolded package id, `cursor-pointer` on every row.

2. Stale alias persisted after re-pick.  When the user renamed an
   alias on the pin-mapping / backplane / IO Table screen, the orphan
   warning correctly fired on the POU variables table — but clicking
   the location dropdown and picking the same address from it did
   nothing, because the cell's `onBlur` short-circuited on
   `value === initialValue`.  The auto-adopt path in `updateVariable`
   never ran, so the variable's stored `alias` field kept pointing at
   the now-deleted name.  Two fixes:

   - `editable-cell.tsx` (local + global tables): lift the
     `isOrphaned` computation above `onBlur` and skip the
     short-circuit when the alias is orphaned, so re-picking the
     same address forces the alias re-resolve through to
     `updateVariable`'s auto-adopt branch.
   - `validation/variables.ts`: `checkIfLocationExists` now takes
     an optional `exclude: PLCVariable` so the update path can
     skip the variable being mutated.  Without this, the re-pick
     would be rejected with "Location already exists" because the
     uniqueness check found the variable's own existing entry.
   - Regression test: re-setting a variable to its current
     location must return `ok: true`.

3. Console auto-scroll was laggy / skipped messages.  Root cause was
   the prior implementation's 75ms `lodash.debounce` plus a
   `scrollIntoView({behavior: 'instant'})` call — `'instant'` isn't
   a standard option and silently falls back to smooth-scroll on
   some Chromium builds, which IS the lag.  Replaced the whole logic
   with a VSCode-style sticky-bottom:

   - `useLayoutEffect` writes `scrollTop = scrollHeight` synchronously
     after each render where logs changed (no debounce, no
     `scrollIntoView` indirection, no flash of old scroll position).
   - `stickToBottomRef` flips false when the user scrolls up; flips
     back true the moment they manually reach the bottom again.
     Programmatic scrolls land at the bottom and keep stick=true,
     so no feedback loop.
   - 4-px slack on the at-bottom check accounts for sub-pixel layout.

80 validation tests pass (+1 regression).  Console + log tests still
green.  tsc -b clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… rename

Two related issues that surfaced together when users tried to rename
the template-seeded "main" program.

1. Remove the hardcoded "main" POU requirement.
   The compiler hasn't required a POU named "main" for a long time —
   any program POU name compiles, and the entry program is picked
   from the configuration's `instances[]`.  The editor still had a
   v3-era guard in `XmlGenerator` that returned `Main POU not found`
   when no program POU was called "main", which is what users hit
   the moment they renamed the template default.

   - `xml-generator.ts`: dropped the guard (the `mainPou` variable
     wasn't used downstream anyway — pous get iterated by
     `oldEditorParsePousToXML(pous)` regardless).
   - `updatePouName`: cascades program POU renames into matching
     `configurations.resource.instances[]`, so the instance
     binding follows the rename and the IEC compile still finds
     its entry program.
   - `shared/slice.ts` auto-open-tab: prefers "main" when present,
     otherwise falls back to the first program POU.  No silent
     failure when the project has no "main".
   - Tests: dropped the assertions that the XML generator MUST
     fail without a "main" POU; added two cases for the new
     behaviour (zero POUs, function-only, non-"main" program).
     Regression test for the instance-cascade behaviour.

2. MAJOR — LD/FBD rungs disappeared after renaming the POU.
   The graphical-flow slices (`ladderFlows`, `fbdFlows`) store the
   canvas state keyed by POU name in a separate Zustand slice.  The
   shared `renameElement` helper rekeyed the editor model, file
   slice, and tab slice — but never touched the flow slices.  A
   renamed LD POU's editor immediately looked up
   `ladderFlows.find(f => f.name === newName)` → undefined → blank
   canvas.  Continuing to edit on the blank canvas and saving
   would have overwritten the on-disk rungs (real data loss).

   The graphical body itself also embeds a `name` field inside
   `pou.body.value`, used by the project-load path as the
   ladderFlows seed key.  The rename only updated `pou.name`,
   leaving `body.value.name` pointing at the old name — projects
   saved in that state would render an empty canvas under the new
   POU name on every reopen.

   Fixes:
   - `renameLadderFlow(oldName, newName)` + `renameFBDFlow(...)`:
     rekey the flow entry in place; defensively drop any empty
     placeholder that may have been cold-seeded under the new
     name so the original rungs win, not the placeholder.
   - `renameElement`: calls both unconditionally (no-op for
     non-graphical POUs).
   - `updatePouName`: also rewrites `pou.body.value.name` for
     LD/FBD bodies so the on-disk JSON stays consistent.
   - `handleOpenProjectResponse`: defends against legacy drift —
     when seeding flows from disk, override `body.value.name`
     with `pou.name`.  Projects saved with the new (consistent)
     rename path see no change; projects with the legacy drift
     (saved during the window where the bug existed) auto-
     recover on first open.

   Regression tests:
   - `renameLadderFlow` rekeys without losing rungs
   - drops a stale empty placeholder under the new name
   - no-op on same-name and on missing flow
   - `updatePouName` syncs `body.value.name` for LD POUs

854 tests pass; tsc -b clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion

The install handler swallows non-zero exit codes as warnings and
always resolves, so the promise constructor's `reject` argument is
intentionally unused.  CI's `@typescript-eslint/no-unused-vars`
flagged it on the PR; underscore prefix matches the rule's
allowed-unused pattern `/^_/u`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thiagoralves
thiagoralves marked this pull request as ready for review June 4, 2026 21:33
thiagoralves and others added 4 commits June 4, 2026 17:36
Web's eslint config enforces `simple-import-sort/imports` more
strictly than editor's local config, but the file MUST stay byte-
identical across the two repos.  Applied the sort so both pass —
the only change is reordering two adjacent middleware/shared imports
(library/ before target-capabilities), no logic change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror of openplc-web commit 9fa25df9a6 — keeps the shared pipeline
file byte-identical between repos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror of openplc-web's format pass — Prettier 3.4.2 reflows in the
shared surface files (generate-vpp-config, resolve-module-channels,
module-slots layout, shared slice rename, tests).  Format-only, no
logic change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thiagoralves
thiagoralves merged commit cb1d378 into development Jun 4, 2026
11 of 12 checks passed
@thiagoralves
thiagoralves deleted the feat/vpp-compile-pipeline-port branch June 4, 2026 22:15
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