What each check can and can't catch¶
plc-st-review is a static reviewer, it works from the syntax tree, not from running code. That means every check has things it can see and things it can't. This page is the honest accounting.
The single biggest limitation across the whole tool: there is no flow analysis. Nothing knows what value a variable holds at runtime. So anywhere the check needs "the value of i" or "is this branch reachable", the answer is approximated from literals and resolvable constants only.
Diff-based checks (compare before vs after)¶
These fire when something changes between revisions.
| Check | Catches | Doesn't catch |
|---|---|---|
SIGNATURE_CHANGED |
POU input / output / in-out renamed, removed, type-changed; new inputs without defaults | Argument order changes when names stay the same (we go by name) |
CALL_SITE_OUTDATED |
Callers missing newly-required inputs; unknown named arguments; FB-instance calls (fbConveyor(...) resolves through fbConveyor : FB_Conveyor) |
Positional-arg calls beyond the simplest shape; calls through super or pointers |
TYPE_MISMATCH |
VAR_GLOBAL type changes |
Type changes on local vars, parameters, struct fields (covered indirectly by SIGNATURE_CHANGED for params) |
ENUM_VALUE_REMOVED |
A CASE references a value that was removed from the enum | Comparisons (if x = E.GONE), only CASE values are walked |
ENUM_VALUE_ADDED |
Enum gains a value, CASE on that enum doesn't handle it and has no ELSE | CASEs whose switch expression doesn't surface the enum name |
TIMER_VALUE_CHANGED |
TON/TOF/TP PT changes (via named arg or explicit .PT :=) |
Other timer parameters; PT changes computed at runtime |
CONSTANT_VALUE_CHANGED |
VAR_GLOBAL CONSTANT initial value changes |
Constants declared inside POUs (no VAR_GLOBAL) |
COMMENT_ONLY |
File's AST is structurally identical between revisions | Adding/removing a no-op statement that compiles away |
ARRAY_BOUNDS_CHANGED |
A VAR : ARRAY [a..b] whose a or b changed |
Multi-dim arrays where only one dimension changes (we report on the first range only) |
STATE_UNHANDLED |
Any CASE on an enum that doesn't cover every value and has no ELSE | Same blind-spots as ENUM_VALUE_ADDED |
UNREACHABLE_CODE |
Statement immediately after RETURN / EXIT / CONTINUE that's newly added | Code that's unreachable due to a constant-FALSE branch or impossible value comparison |
LOOP_BOUNDS_CHANGED |
FOR-loop start / end / step changed; severity scales with the iteration-count ratio | Loops where bounds depend on local vars |
POU_DELETED |
A FB / function / program disappears; callers still referencing it | Calls through pointer / interface refs whose target is the deleted POU |
POU_RENAMED |
Heuristic, a delete + add with identical input / output / in-out signatures, in the same review | Renames that also tweaked the signature |
METHOD_ADDED_TO_INTERFACE |
Interface gains a method, an FB that IMPLEMENTS it doesn't declare one |
Inherited methods via EXTENDS (we walk only direct methods per POU) |
INHERITANCE_CHANGED |
EXTENDS clause added / removed / changed |
New method on the base that the derived FB now silently inherits |
PRAGMA_CHANGED |
The set of pragmas in a file is different between revisions | Semantic effect of the pragma (we don't know what your codegen does with it) |
UNUSED_VAR_INTRODUCED |
New local declared in this PR, never referenced in its scope | Vars whose only use is via reflection / pragma-driven generated code |
COMPLEXITY_INCREASED |
A POU present in both revisions whose cyclomatic complexity rose | Complexity moved into a new helper POU (each piece looks fine); methods are scored as part of the enclosing FB, not separately |
NESTING_INCREASED |
A POU whose maximum control-structure nesting depth rose past the warn band | Refactors that keep the same max depth while adding many sibling branches; depth reduced in one branch but added in another at equal max |
LOC_SPIKE |
A POU present in both revisions whose LOC grew by more than 50% | Brand-new POUs (no prior revision to compare); growth spread thinly across many POUs |
DEAD_POU_INTRODUCED |
A new FUNCTION / FUNCTION_BLOCK with no caller anywhere in the project (needs --project-scope) |
Callers reached only through pointers / interface refs; instantiated-but-never-invoked FBs (that is FB_INSTANCE_NEVER_CALLED); anything when project scope is not enabled |
Static checks (look at the new revision in isolation)¶
These fire on bugs that exist in the new code, regardless of whether the PR introduced them. By default they only flag bugs that weren't already present in the old revision, adopting the tool on a legacy repo shouldn't dump a wall of pre-existing findings on day one. Toggle that filter via severity_overrides or disabled_checks if you want all hits.
| Check | Catches | Doesn't catch |
|---|---|---|
ENUM_VALUE_UNUSED |
Enum value declared but never referenced anywhere, dead state | Values referenced only through reflection / generated code |
ENUM_MEMBER_UNKNOWN |
E_State.IDEL typo when the enum's actual member is IDLE |
Typos in non-enum member-access (e.g. struct fields), comparisons against integer literals, dynamic strings |
ARRAY_INDEX_OUT_OF_BOUNDS |
arr[15] when the array's bounds are [0..9] |
Variable indices (arr[i]), indices computed by expression, accesses through a pointer to the array, and second-and-later subscripts of multi-dimensional arrays (arr[3, 99] against ARRAY [0..9, 0..5] — only the first subscript is checked) |
DIVISION_BY_ZERO |
x / 0 literal, or x / cZero when cZero is a VAR_GLOBAL CONSTANT resolving to 0 |
Divisors computed at runtime, x / (a - b) where a and b happen to be equal |
INFINITE_LOOP |
WHILE TRUE DO ... END_WHILE; with no EXIT inside |
WHILE x DO ... where x is never updated inside the body (would need def-use analysis) |
LOOP_BOUNDS_REVERSED |
FOR i := 10 TO 5 BY 1 (positive step with start > end); FOR i := 1 TO 10 BY -1 (negative step with start < end). Per spec the body never runs; on runtimes that wrap integer overflow it runs hundreds of times |
Loops whose start, end, or step are non-constant expressions |
COUNTER_VALUE_CHANGED |
CTU/CTD/CTUD PV changed between revisions; severity by ratio |
Counters whose PV is a runtime variable |
COUNTER_PV_ZERO |
C1(PV := 0) or C1.PV := 0 or a CONSTANT-resolving-to-0 PV |
PV computed at runtime |
TIMER_PT_ZERO |
T1(PT := T#0s) or CONSTANT-resolving-to-0 PT |
PT computed at runtime |
TIMER_NOT_DRIVEN |
A timer instance whose .Q / .ET is read but no call sets IN |
Timers driven through indirect / pointer / reference access |
EDGE_TRIG_REUSED |
R_TRIG / F_TRIG invoked with two or more different CLK expressions |
CLK expressions that are syntactically different but semantically equivalent (xA vs (xA)) |
FB_INSTANCE_DOUBLE_CALL |
Same FB instance invoked more than once in the same POU scope | Multiple calls intentionally placed inside mutually-exclusive CASE/IF branches, we don't track control flow |
FB_INSTANCE_NEVER_CALLED |
FB instance whose outputs are read but no call site exists | Instances whose only use is via EXTENDS-inherited methods (we'd need full-inheritance walking) |
BISTABLE_DOMINANCE_MISMATCH |
SR named like a reset-dominant latch, or RS named like a set-dominant one. Heuristic, name-pattern based |
Shops with non-conventional naming will see false positives. Disable if not useful |
FOR_LOOP_VAR_MODIFIED |
Direct assignment to a FOR loop counter (i := ...) inside the loop body (PLCopen L12) |
Indirect mutation through a pointer to the counter, or via an out-of-scope alias |
FOR_LOOP_VAR_USED_AFTER |
Read of the loop counter after END_FOR in the same POU (PLCopen L13) |
Counter use via a pointer / alias after the loop; counter read inside a nested POU |
POINTER_ARITHMETIC |
Binary + / - / * / / where one operand is a local declared with POINTER TO ... (PLCopen E2) |
Pointer arithmetic through a REFERENCE TO (refs are out of scope), or via a global typed POINTER |
POINTER_COMPARED |
Relational < / > / <= / >= where one operand is a POINTER-typed local (PLCopen E3) |
Pointer equality (= / <>) — those are allowed by the standard; comparisons against NULL |
UNINITIALIZED_VAR_USED |
A local read whose source line precedes the first assignment to it (source-position heuristic) | Control-flow paths the heuristic doesn't model (e.g. an IF-guarded init before the read on the same line); cross-POU initialisation |
TIME_EQUALITY |
= / <> between two operands when either side is a TIME literal or a TIME-typed local (PLCopen CP28) |
TIME equality through an integer cast; equality on TIME_OF_DAY or DATE (out of scope of the check) |
MULTI_WRITER_GLOBAL |
A global variable written by two or more PROGRAM POUs in the same project parse (PLCopen CP26). Needs --project-scope |
Writes through a pointer / member access where the LHS root isn't the bare global name; writes inside non-PROGRAM POUs |
DIRECT_ADDRESS_USED |
%I0.0, %QW100, %MB42 etc. in source (PLCopen N1 / CP1) |
Direct addresses hidden behind a global alias — once the address is named, this check doesn't fire on the alias |
IF_WITHOUT_ELSE |
An IF / ELSIF chain with no terminal ELSE clause (PLCopen L17) |
CASE statements without ELSE (those are handled by STATE_UNHANDLED); IF whose only branch is a single statement |
FORBIDDEN_STATEMENT |
EXIT / CONTINUE / GOTO use sites (PLCopen L10) |
RETURN — that has its own check via MULTIPLE_EXIT_POINTS |
POU_NOT_COMMENTED |
POU declared without a leading line / block comment (PLCopen C2) | Comments inside the POU body — only the header is examined |
NAME_REUSED_DIFFERENT_KIND |
The same case-folded identifier appears across two or more different declaration kinds (POU + global, enum value + constant, etc.) (PLCopen N9) | Same-kind clashes (two POUs named FB_X in different namespaces — that's L15 POU-metrics territory) |
INDIRECT_RECURSIVE_CALL |
A cycle of length ≥ 2 in the static call graph, e.g. A → B → A (PLCopen CP13 indirect) | Cycles routed through method-table indirection or function pointers, where the engine can't follow the indirection |
IDENTIFIER_TOO_LONG |
Any declared identifier longer than limits.max_identifier_length (PLCopen N6) |
The limit is null (off) by default — the check does nothing until you set it (or use the PLCopen preset, which sets 32) |
IDENTIFIER_CHARSET |
Any declared identifier that doesn't fully match the identifier_charset regex (PLCopen N8) |
Off by default (regex is null); only declared identifiers are tested — references through library code aren't |
TOO_MANY_PARAMETERS |
A POU whose total parameter count (inputs + outputs + in-outs) exceeds limits.max_parameters (PLCopen CP23) |
null (off) by default; multi-name per-line declarations (a, b, c : INT;) — those collapse to one decl in the AST and are counted once |
TOO_MANY_GLOBALS_USED |
A POU that references more than limits.max_globals_used_per_pou distinct globals (PLCopen CP18) |
null (off) by default; globals reached through a member access on a struct global are counted as one use of the struct, not N |
EXTERNAL_VAR_IN_FUNCTION |
VAR_EXTERNAL declared inside FUNCTION / FUNCTION_BLOCK / METHOD bodies (PLCopen CP6) |
VAR_EXTERNAL inside a PROGRAM — PLCopen permits that |
IMPLICIT_TYPE_CONVERSION |
Arithmetic mixing an INT-family operand with a REAL-family operand without an explicit INT_TO_REAL / etc. cast (PLCopen CP25) |
Full IEC essential-type model isn't modelled; conversions between INT widths (SINT → INT) aren't flagged |
HARDCODED_CREDENTIALS |
Variable named password / secret / apikey / token / credential / private_key etc. with a non-placeholder STRING initialiser (IEC 62443-4-2 CR 1.5) |
Credentials assembled at runtime from non-string sources; placeholders (<CHANGE_ME>, ***, xxx, TODO) are intentionally skipped |
HARDCODED_NETWORK_ENDPOINT |
STRING literal that's a strict IPv4 dotted-quad (with optional :port) or an http(s)/tcp/udp/opc(.tcp)/mqtt(s)/modbus/ssh/ftp URL (IEC 62443-4-1 SI-1) |
Loopback / unspecified (127.0.0.1, 0.0.0.0, ::1, localhost) are allowlisted; endpoints built at runtime from concatenated strings |
UNVALIDATED_INPUT_USE |
VAR_INPUT used directly as an array subscript or divisor when no relational comparison on that input exists anywhere in the same POU (IEC 62443-4-2 CR 3.5) |
Caller-side validation isn't visible (the check only sees the called POU's body); compound expressions like arr[idx + 1] aren't flagged |
DEBUG_PRAGMA_IN_PRODUCTION |
Pragma text matching debug / test / monitoring / force_init / trace / instance-path in a non-test source path (IEC 62443-4-1 SI-2) |
Files under tests/ / test/ / examples?/ / fixtures?/ paths or ending in _test.st are skipped; custom debug-pragma names not on the default list |
PERSISTENT_PLAINTEXT_SECRET |
VAR_GLOBAL PERSISTENT or VAR_GLOBAL RETAIN whose name matches a secret pattern (IEC 62443-4-2 CR 4.1) |
Plain VAR_GLOBAL (no PERSISTENT / RETAIN) of a secret name — that case is covered by HARDCODED_CREDENTIALS when an initial value exists |
EMPTY_STATEMENT |
Lone ; |
Empty statements inside synthesized macro / pragma expansions if you use them |
UNUSED_RETURN_VALUE |
Bare-statement call of a FUNCTION POU with a declared return type |
Discarded returns from external library functions whose signatures we don't see, calls via pointer-to-function |
ARRAY_SINGLE_ELEMENT |
ARRAY [n..n] OF T where bounds are literals |
Bounds expressed as named constants that happen to evaluate equal (we'd need constant resolution per dimension); multi-dim arrays where only one dimension is [n..n] (only the first dimension is examined) |
VARIABLE_SHADOWING |
Local declaration with the same name as a VAR_GLOBAL |
Shadowing across nested scopes, methods inside an FB hiding the FB's locals, we only check local-vs-global |
UNQUALIFIED_ENUM_CONSTANT |
Bare identifier reference uniquely matching one enum's member | Member names shared by multiple enums (ambiguous), members already accessed via member_access_expression |
IDENTIFIER_CASE_MISMATCH |
Reference uses different case than the declared identifier | Library-provided identifiers whose canonical case we don't see |
UNUSED_INPUT_VAR |
VAR_INPUT with no read reference in the POU's body |
Inputs read indirectly via reflection / pragma-driven generated code |
INPUT_VAR_WRITTEN |
LHS of an assignment matches a VAR_INPUT declared in the enclosing POU |
Writes through pointer-to-input (rare in IEC ST) |
BOOL_COMPARISON |
binary_expression with =/<> and a BOOLEAN_LITERAL operand |
Boolean comparisons via custom infix functions, comparisons against variables that happen to hold TRUE/FALSE |
REAL_EQUALITY |
=/<> against a REAL_LITERAL |
Comparisons of two non-literal REALs (rA = rB), likely also unsafe but we can't tell statically |
MULTIPLE_EXIT_POINTS |
More than one RETURN per POU |
EXIT statements inside loops (those leave the loop, not the POU) |
ASSIGNMENT_IN_CONDITION |
:= directly inside IF/WHILE/REPEAT condition lines |
Assignments deep inside a function-call argument that's then used in the condition |
COMMENTED_OUT_CODE |
Comment text matches an ST keyword or := heuristic |
Prose comments that happen to contain IF or := in different contexts |
RECURSIVE_CALL |
Direct self-call by name | Mutual recursion (A → B → A), would need call-graph traversal |
FORBIDDEN_SYMBOL |
Identifier matches a configured pattern in forbidden_symbols |
Symbols referenced only via dynamic-string-construction lookups |
ADDRESS_OF_CONSTANT |
ADR(c) where c is a VAR_GLOBAL CONSTANT |
ADR(localConstant): locals don't have CONSTANT-marked symbols in our table |
UNUSED_OUTPUT_VAR |
VAR_OUTPUT with no LHS-of-assignment match in the POU's body |
Outputs written via pointer indirection |
OUTPUT_VAR_READ_INTERNALLY |
VAR_OUTPUT referenced in a non-assignment position in its own POU |
Reads through pointer indirection |
NESTED_COMMENTS |
(* inside a (* ... *) block comment |
(* inside a string literal (parser usually handles this; we trust it) |
NAMING_CONVENTION |
Declaration name violates configured prefix / suffix / pattern | Identifiers that don't match any declared dimension (we only know the kinds we model) |
When a check has no opinion¶
A check that doesn't fire isn't necessarily a sign that the code is fine, just that nothing this check looks at is wrong. If you want broader coverage of a category, file an issue with a concrete example: it's easier to extend an existing check than to argue about what one means.