# Code Review — Driver.AbCip.Cli

| Field | Value |
|---|---|
| Module | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli` |
| Reviewer | Claude Code |
| Review date | 2026-06-19 |
| Commit reviewed | `c95a8c6b` |
| Status | Reviewed |
| Open findings | 0 |

## Checklist coverage

A comprehensive review completes every category, recording "No issues found" where
a category produced nothing rather than leaving it blank.

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Driver.AbCip.Cli-001, Driver.AbCip.Cli-002 |
| 2 | OtOpcUa conventions | No issues found |
| 3 | Concurrency & thread safety | Driver.AbCip.Cli-003 |
| 4 | Error handling & resilience | Driver.AbCip.Cli-001, Driver.AbCip.Cli-004 |
| 5 | Security | No issues found |
| 6 | Performance & resource management | Driver.AbCip.Cli-005 |
| 7 | Design-document adherence | Driver.AbCip.Cli-006 |
| 8 | Code organization & conventions | No issues found |
| 9 | Testing coverage | Driver.AbCip.Cli-007 |
| 10 | Documentation & comments | Driver.AbCip.Cli-008 |

## Findings

<!-- One ### entry per finding. IDs are <Module>-NNN, sequential within the module,
     never reused. Findings are never deleted — close them by changing Status and
     completing Resolution. -->

### Driver.AbCip.Cli-001

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/WriteCommand.cs:70-85` |
| Status | Resolved |

**Description:** `ParseValue` parses every numeric Logix type with the BCL `*.Parse`
methods (`sbyte.Parse`, `short.Parse`, `int.Parse`, `float.Parse`, ...). These throw
the raw `FormatException` and `OverflowException` on bad operator input. The module's
own test `ParseValue_non_numeric_for_numeric_types_throws` confirms a raw
`FormatException` escapes for `DInt`. Meanwhile the `Bool` branch and the `_ =>`
default branch throw the CLI-friendly `CliFx.Exceptions.CommandException` with an
actionable message. The result is inconsistent operator UX: a typo in a boolean
value prints "Boolean value 'x' is not recognised...", but a typo in a numeric
value (`write -v 12x --type DInt`, or an out-of-range `write -v 99999999999 --type
Int`) escapes uncaught and CliFx renders a full .NET stack trace instead of a
one-line error. CliFx only formats `CommandException` cleanly.

**Recommendation:** Wrap the numeric `*.Parse` calls (or the whole `switch`) in a
`try`/`catch (Exception ex) when (ex is FormatException or OverflowException)` that
rethrows as a `CommandException` with the raw value, the target `--type`, and the
valid range — mirroring the `ParseBool` failure message.

**Resolution:** Resolved 2026-05-22 — wrapped the `ParseValue` switch in `try/catch (FormatException or OverflowException)` that rethrows as `CommandException` with the raw value and type; updated the previously-passing `ParseValue_non_numeric_for_numeric_types_throws` test to assert `CommandException` and added two new tests covering overflow and actionable message content.

### Driver.AbCip.Cli-002

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/ProbeCommand.cs:21-23`; `Commands/ReadCommand.cs:24-25`; `Commands/SubscribeCommand.cs:20-22` |
| Status | Resolved |

**Description:** `ProbeCommand`, `ReadCommand`, and `SubscribeCommand` expose
`--type` as a free `AbCipDataType` enum option with no exclusion of
`AbCipDataType.Structure`. Only `WriteCommand` rejects `Structure` (with an explicit
`CommandException`). Passing `probe/read/subscribe --type Structure` synthesises a
tag with `DataType = Structure` and no `Members` declared. The driver read path
treats a memberless Structure tag as a black box and routes it to the per-tag
fallback, where `runtime.DecodeValue(AbCipDataType.Structure, ...)` runs with no
declared layout — the operator gets either an opaque value or a confusing status
code rather than the clean "Structure writes need an explicit member layout"
guidance `write` gives. The `read` doc comment even claims "UDT / Structure reads
are out of scope here", but the code does not enforce it.

**Recommendation:** Reject `AbCipDataType.Structure` in `ProbeCommand`,
`ReadCommand`, and `SubscribeCommand` `ExecuteAsync` with the same `CommandException`
pattern `WriteCommand` uses, or factor a shared `RejectStructure(DataType)` guard
into `AbCipCommandBase`.

**Resolution:** Resolved 2026-05-22 — added `RejectStructure(AbCipDataType)` static helper to `AbCipCommandBase` that throws `CommandException` for `Structure`; called at the top of `ExecuteAsync` in `ProbeCommand`, `ReadCommand`, and `SubscribeCommand`.

### Driver.AbCip.Cli-003

| Field | Value |
|---|---|
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/SubscribeCommand.cs:50-56,60-61` |
| Status | Resolved |

**Description:** The `OnDataChange` handler writes change lines to `console.Output`
(a `TextWriter`) from the driver's poll-engine callback thread, while the command's
main flow concurrently writes the "Subscribed to ... Ctrl+C to stop." line on the
CLI thread. `TextWriter.WriteLine` is not guaranteed thread-safe; concurrent writes
from the poll thread and the main thread can interleave or, in the worst case,
corrupt buffered output. The window is small (one main-thread write right after
`SubscribeAsync`) but it exists, and any future addition of main-thread output
during the watch loop widens it.

**Recommendation:** Emit the "Subscribed..." banner before registering the
`OnDataChange` handler (or before `SubscribeAsync`), or guard all `console.Output`
writes during the subscription with a shared lock so poll-thread and main-thread
output cannot interleave.

**Resolution:** Resolved 2026-05-23 — moved the "Subscribed to ... Ctrl+C to stop."
banner write to BEFORE `driver.OnDataChange +=` (and therefore before
`SubscribeAsync`). With the handler not yet attached when the banner runs, the
poll thread cannot fire change events into `console.Output` concurrently with the
main-thread banner write. After `+=` the only writer to `console.Output` is the
poll-thread handler, so no interleaving is possible.

### Driver.AbCip.Cli-004

| Field | Value |
|---|---|
| Severity | Low |
| Category | Error handling & resilience |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/SubscribeCommand.cs:28,58`; `AbCipCommandBase.cs:26-34` |
| Status | Resolved |

**Description:** `--interval-ms` (`IntervalMs`) is taken verbatim and passed as
`TimeSpan.FromMilliseconds(IntervalMs)` to `SubscribeAsync` with no validation. A
zero or negative value produces a non-positive `TimeSpan`; the option description
claims "PollGroupEngine floors sub-250ms values" but says nothing about `0` or
negatives, and the flooring behaviour is the engine's, not the CLI's — relying on a
downstream component to sanitise operator input is fragile. `--timeout-ms` on
`AbCipCommandBase` has the same gap (a negative value yields a negative `TimeSpan`).

**Recommendation:** Validate `IntervalMs > 0` and `TimeoutMs > 0` at the top of
`ExecuteAsync` / in `AbCipCommandBase`, throwing a `CommandException` with the
accepted range when out of bounds.

**Resolution:** Resolved 2026-05-23 — added `SubscribeCommand.ValidateInterval(int)`
(throws `CommandException` for `IntervalMs <= 0`) called at the top of
`SubscribeCommand.ExecuteAsync`; moved `TimeoutMs > 0` validation into the
`AbCipCommandBase.Timeout` getter (throws `CommandException` for non-positive
`TimeoutMs`) and added a `_ = Timeout` touch in `SubscribeCommand.ExecuteAsync` to
fire that guard before the driver opens. Other commands trip the same guard
naturally via `BuildOptions(...).Timeout`. Regression tests
`AbCipCommandBaseTests.Timeout_get_throws_CommandException_when_TimeoutMs_is_non_positive`
and `SubscribeCommandIntervalTests.ValidateInterval_rejects_non_positive` cover
both paths.

### Driver.AbCip.Cli-005

| Field | Value |
|---|---|
| Severity | Low |
| Category | Performance & resource management |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs:51-59` |
| Status | Resolved |

**Description:** `ConfigureLogging` assigns a freshly created Serilog logger to the
process-global `Log.Logger` but never calls `Log.CloseAndFlush()`. For a short-lived
one-shot command (`probe`, `read`, `write`) the process exit flushes the console
sink, so the practical impact is nil. For `subscribe` — a long-running command
terminated by Ctrl+C — buffered log lines emitted just before cancellation can be
lost on abrupt exit. (This lives in the shared `Driver.Cli.Common` base, so it is
noted here as it affects the AB CIP CLI; the canonical fix belongs in that shared
module's review.)

**Recommendation:** Register `Log.CloseAndFlush()` on process exit (e.g. via
`AppDomain.ProcessExit` or a `finally` in the command), or have the CLI use a
disposable logger scoped to `ExecuteAsync`.

**Resolution:** Resolved 2026-05-23 — `Driver.Cli.Common` already exposes
`DriverCommandBase.FlushLogging()` (a `Log.CloseAndFlush()` wrapper); the AB CIP
CLI was not calling it. Added `FlushLogging()` in the `finally` block of all four
commands (`ProbeCommand`, `ReadCommand`, `WriteCommand`, `SubscribeCommand`) so
buffered Serilog output is flushed before the process exits, including the
Ctrl+C-driven `subscribe` path. No edits to `Driver.Cli.Common` were needed.

### Driver.AbCip.Cli-006

| Field | Value |
|---|---|
| Severity | Low |
| Category | Design-document adherence |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs:29-34` |
| Status | Resolved |

**Description:** `AbCipCommandBase` overrides the abstract `DriverCommandBase.Timeout`
property with a getter derived from `TimeoutMs` and an empty `init` body
(`init { /* driven by TimeoutMs */ }`). Because the override has no
`[CommandOption]` attribute, CliFx never binds it, so the empty `init` is unreachable
in normal CLI use. However, an empty `init` accessor silently discards any
assignment — if a future caller or test constructs the command via an object
initializer (`new ReadCommand { Timeout = ... }`) the assignment is silently dropped
with no compiler warning. This is a latent correctness trap rather than a current
bug.

**Recommendation:** Either drop the `init` accessor entirely (make the override a
get-only expression-bodied property) or have the empty `init` throw
`NotSupportedException` to make the "driven by TimeoutMs" contract explicit and
fail-fast. (Drop is not viable because the abstract base declares `{ get; init; }`
and an override must provide both accessors.)

**Resolution:** Resolved 2026-05-23 — the `init` accessor now throws
`NotSupportedException` with a message pointing the caller at `TimeoutMs`. A new
test `AbCipCommandBaseTests.Timeout_setter_is_inert_and_does_not_silently_swallow_assignments`
asserts that an object-initializer assignment to `Timeout` fails fast.

### Driver.AbCip.Cli-007

| Field | Value |
|---|---|
| Severity | Low |
| Category | Testing coverage |
| Location | `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/WriteCommandParseValueTests.cs` |
| Status | Resolved |

**Description:** The only test file covers `WriteCommand.ParseValue` and
`ReadCommand.SynthesiseTagName` — both pure static helpers. There is no coverage for
`AbCipCommandBase.BuildOptions` (the flag-to-`AbCipDriverOptions` mapping that all
four commands depend on) or `DriverInstanceId`. `BuildOptions` is pure and trivially
unit-testable yet untested: a regression that, say, flipped `EnableAlarmProjection`
back on or dropped `Probe.Enabled = false` would not be caught — and the comment
explicitly warns the probe loop "would race the operator's own reads", so that
mapping is behaviourally load-bearing. The `ExecuteAsync` bodies are reasonably left
untested (they need a fake `AbCipDriver` or hardware), consistent with the other
driver CLIs.

**Recommendation:** Add unit tests asserting `BuildOptions` produces
`Probe.Enabled == false`, `EnableControllerBrowse == false`,
`EnableAlarmProjection == false`, the expected single `AbCipDeviceOptions`
(`HostAddress`, `PlcFamily`, `DeviceName`), the supplied tag list, and the `Timeout`
derived from `TimeoutMs`.

**Resolution:** Resolved 2026-05-23 — added
`tests/.../AbCipCommandBaseTests.cs` covering `BuildOptions` (probe disabled,
controller-browse disabled, alarm-projection disabled, single device with
`HostAddress` / `PlcFamily` / `cli-{Family}` device name, tag list passed verbatim,
`Timeout` derived from `TimeoutMs`) and `DriverInstanceId` (`abcip-cli-{Gateway}`),
plus the `RejectStructure` guard (throws for `Structure`, no-op for atomic types).

### Driver.AbCip.Cli-008

| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `docs/Driver.AbCip.Cli.md:8-9` |
| Status | Resolved |

**Description:** `docs/Driver.AbCip.Cli.md` opens with "Second of four driver
test-client CLIs (Modbus -> AB CIP -> AB Legacy -> S7 -> TwinCAT)." The count "four"
contradicts the chain that follows it (five names) and contradicts
`docs/DriverClis.md`, which documents six CLIs (Modbus, AB CIP, AB Legacy, S7,
TwinCAT, FOCAS). The FOCAS CLI shipped alongside the Tier-C work, so the AB CIP
doc's "four" and the truncated chain are both stale.

**Recommendation:** Update the sentence to "Second of six driver test-client CLIs"
and complete the chain (Modbus -> AB CIP -> AB Legacy -> S7 -> TwinCAT -> FOCAS), or
drop the explicit count and link `docs/DriverClis.md` as the authoritative roster.

**Resolution:** Resolved 2026-05-23 — rewrote the lead paragraph to "Second of six
driver test-client CLIs (Modbus -> AB CIP -> AB Legacy -> S7 -> TwinCAT -> FOCAS)"
and added a link to `docs/DriverClis.md` as the authoritative roster.

## Re-review 2026-06-19 (commit c95a8c6b)

All eight findings from the 2026-05-22 review were confirmed resolved at this commit.
The four commits that landed between the prior review and HEAD (two fix commits resolving -001 through -008, one XML-doc backfill, one central-package-management migration) were inspected. Two new low-severity findings were recorded and immediately fixed.

#### Re-review checklist coverage

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | No new issues found |
| 2 | OtOpcUa conventions | No issues found |
| 3 | Concurrency & thread safety | No new issues found (Driver.AbCip.Cli-003 confirmed resolved) |
| 4 | Error handling & resilience | No new issues found |
| 5 | Security | No issues found |
| 6 | Performance & resource management | No new issues found |
| 7 | Design-document adherence | No new issues found |
| 8 | Code organization & conventions | No issues found |
| 9 | Testing coverage | No new gaps found |
| 10 | Documentation & comments | Driver.AbCip.Cli-009, Driver.AbCip.Cli-010 |

### Driver.AbCip.Cli-009

| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs:9` |
| Status | Resolved |

**Description:** `Program.cs` `SetDescription` still says "Second of **four** driver CLIs"
after finding Driver.AbCip.Cli-008 corrected the matching sentence in `docs/Driver.AbCip.Cli.md`.
The FOCAS and TwinCAT CLIs ship alongside the other four, making six total; the stale count
in the process banner is visible in `--help` output.

**Recommendation:** Change "four" to "six" in `Program.cs:9` to match the corrected
`docs/Driver.AbCip.Cli.md` wording.

**Resolution:** Resolved 2026-06-19 (SHA blank) — changed "Second of four driver CLIs" to
"Second of six driver CLIs" in `Program.cs:9`.

### Driver.AbCip.Cli-010

| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/ReadCommand.cs:26` |
| Status | Resolved |

**Description:** `ReadCommand`'s `--type` `[CommandOption]` description lists
`Structure` as a valid type value (`"... / Dt / Structure (default DInt)."`).
Finding Driver.AbCip.Cli-002 added `RejectStructure(DataType)` at the top of
`ReadCommand.ExecuteAsync`, so passing `--type Structure` is refused at runtime
with a `CommandException`. The help text still advertises `Structure` as an accepted
option, misleading operators who will get a rejection immediately after selecting it.

**Recommendation:** Remove `Structure` from the `--type` description and replace it
with a note indicating UDTs are unsupported in the CLI, mirroring the wording used in
the `RejectStructure` exception message.

**Resolution:** Resolved 2026-06-19 (SHA blank) — replaced `"String / Dt / Structure (default DInt)."` with
`"String / Dt (default DInt). UDT / composite types are not supported here — use a full driver config."`
in `ReadCommand`'s `--type` description; added a pinning test
`HelpTextConsistencyTests.ReadCommand_type_option_description_does_not_list_Structure`
(new file `tests/.../HelpTextConsistencyTests.cs`) to guard against reintroduction.
