Skip to content

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

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
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
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)
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.