Skip to content

plc-st-review

Catch the PLC bugs your IDE can't. Semantic linter, code reviewer, and team-style enforcer for IEC 61131-3 Structured Text. Built for CI on PLC codebases that can't be compiled outside the vendor IDE, so your pipeline catches what the compiler never even sees.

Try it in 60 seconds View on GitHub Install from npm

plc-st-review inline review comment example: FB_INSTANCE_NEVER_CALLED

What it catches

A small slice of what the bot would flag on a PR you might ship today:

๐ŸŸฅ error  TIMER_VALUE_CHANGED
Timer T_StartupDelay.PT: T#2s โ†’ T#200ms (10.0ร— faster)

๐ŸŸง warn   CONSTANT_VALUE_CHANGED
Constant SAFETY_TIMEOUT: T#2s โ†’ T#10s
Identifier prefix matches a safety-critical pattern.

๐ŸŸฅ error  ARRAY_INDEX_OUT_OF_BOUNDS
arr[15] is out of declared bounds [0..9]

๐ŸŸฅ error  INFINITE_LOOP
WHILE TRUE loop with no EXIT statement

๐ŸŸง warn   FB_INSTANCE_NEVER_CALLED
FB instance T3 (TON) is read but never invoked
Outputs of an FB only update when the instance is called.

The live demo PR shows the 78 always-on categories firing on a single PR, with the exact inline comments the bot posts. No mock-ups, no edited screenshots. (Two categories โ€” DEAD_POU_INTRODUCED and MULTI_WRITER_GLOBAL โ€” are opt-in: they need a whole-repo parse, which is too slow for every PR, so they're label-triggered; see Project scope.)

Why this matters

Industrial PLC code rarely runs through the kind of pre-merge review that modern software shops take for granted:

  • No headless compiler. TwinCAT, CODESYS, GX Works, the build only happens inside the vendor IDE, on the developer's workstation. CI pipelines can't repeat it.
  • Code review by visual scan. Even good shops review .st diffs by reading them, and the human eye misses ten-times-faster timers, silently-removed enum values, and renamed-but-not-updated POU callers.
  • High blast radius. A wrong TON.PT doesn't crash a test suite; it crashes the conveyor at 03:00.

plc-st-review reads .st files with a real tree-sitter grammar, the same kind GitHub itself uses for syntax highlighting, and flags the changes that matter, deterministically. No LLM involved. Findings are reproducible, the same input always produces the same output, and you can read every check's source code in src/engine/checks/.

Three ways to use it

  • Static linter on every push: plc-st-review --lint "src/**/*.st". 59 single-revision checks for ST bugs (division by zero, infinite loops, timer / counter misuse, output-var reads, unused vars, naming conventions, forbidden symbols, PLCopen-aligned rules, uninitialised-var use, TIME equality, implicit type conversions, IEC 62443 cybersecurity checks, more). No PR workflow required.
  • PR / MR reviewer: posts inline review comments anchored to the lines that triggered findings. Adds 21 diff-based checks (signature drift, outdated call sites, enum removals, EXTENDS swaps, pragma changes, timer / counter value bumps, etc.). Ships as a GitHub Action and a GitLab CI job.
  • Team-style enforcer: drop a .plc-st-review.yml listing your naming_conventions (prefix / suffix / pattern per declaration kind) and forbidden_symbols; both modes pick it up automatically.

The 80 check categories

Category What fires it
Diff-based (21): compare before and after
SIGNATURE_CHANGED POU input / output signature changed
CALL_SITE_OUTDATED call doesn't pass required args, or passes unknown ones
TYPE_MISMATCH global's declared type changed
ENUM_VALUE_REMOVED enum value gone, but still referenced
ENUM_VALUE_ADDED enum gained a value, CASE has no branch and no ELSE
TIMER_VALUE_CHANGED TON / TOF / TP PT changed; severity scales by ratio
CONSTANT_VALUE_CHANGED VAR_GLOBAL CONSTANT value changed
COMMENT_ONLY AST identical, only comments differ
ARRAY_BOUNDS_CHANGED array [lower..upper] bounds changed
LOOP_BOUNDS_CHANGED FOR iteration count changed
POU_DELETED POU removed; callers still reference it
POU_RENAMED POU renamed (same signature)
METHOD_ADDED_TO_INTERFACE new interface method; implementers missing it
INHERITANCE_CHANGED EXTENDS clause changed
PRAGMA_CHANGED pragma added / removed / changed
COUNTER_VALUE_CHANGED CTU / CTD / CTUD PV changed
UNUSED_VAR_INTRODUCED new variable declared but never used
COMPLEXITY_INCREASED POU cyclomatic complexity rose past the threshold
NESTING_INCREASED POU nesting depth rose past the threshold
LOC_SPIKE POU lines of code grew by more than 50% in one PR
DEAD_POU_INTRODUCED new POU nothing in the project calls (needs --project-scope)
Static integrity (15): single-revision, AST-level
ENUM_VALUE_UNUSED enum value declared but never referenced
ENUM_MEMBER_UNKNOWN E_State.IDEL-style typo against a known enum
ARRAY_INDEX_OUT_OF_BOUNDS literal index outside declared bounds
DIVISION_BY_ZERO literal / 0 or constant resolving to 0
INFINITE_LOOP WHILE TRUE with no EXIT
LOOP_BOUNDS_REVERSED FOR step direction disagrees with start / end
STATE_UNHANDLED CASE on enum missing values + no ELSE
UNREACHABLE_CODE statement after RETURN / EXIT / CONTINUE
FOR_LOOP_VAR_MODIFIED loop counter mutated inside the FOR body
FOR_LOOP_VAR_USED_AFTER loop counter read after END_FOR
POINTER_ARITHMETIC + / - / * / / on a POINTER-typed local
POINTER_COMPARED < / > / <= / >= on a POINTER-typed local
UNINITIALIZED_VAR_USED local read before any assignment to it
TIME_EQUALITY = / <> between TIME operands
MULTI_WRITER_GLOBAL global written from more than one PROGRAM (needs --project-scope)
FB-instance integrity (7): standard IEC 61131-3 FB patterns
COUNTER_PV_ZERO counter preset of 0
TIMER_PT_ZERO TON(PT := T#0s) fires immediately
TIMER_NOT_DRIVEN T1.Q read but no call sets IN
EDGE_TRIG_REUSED one R_TRIG fed by multiple CLK
FB_INSTANCE_DOUBLE_CALL same instance called twice in one scope
FB_INSTANCE_NEVER_CALLED instance read but never invoked
BISTABLE_DOMINANCE_MISMATCH SR / RS choice mismatches the name's intent
Code quality / style (32)
EMPTY_STATEMENT lone ;
UNUSED_RETURN_VALUE function called as a bare statement
ARRAY_SINGLE_ELEMENT ARRAY [5..5]
VARIABLE_SHADOWING local hides a global
UNQUALIFIED_ENUM_CONSTANT bare enum value without type prefix
IDENTIFIER_CASE_MISMATCH ICOUNTER vs iCounter
UNUSED_INPUT_VAR VAR_INPUT never read
INPUT_VAR_WRITTEN assignment to a VAR_INPUT
BOOL_COMPARISON IF xButton = TRUE
REAL_EQUALITY IF rValue = 0.5
MULTIPLE_EXIT_POINTS two or more RETURN in a POU
ASSIGNMENT_IN_CONDITION IF x := y THEN
COMMENTED_OUT_CODE commented-out assignment / call
RECURSIVE_CALL FB invokes its own type
FORBIDDEN_SYMBOL reference to a blocklisted identifier (config)
ADDRESS_OF_CONSTANT ADR() of a VAR_GLOBAL CONSTANT
UNUSED_OUTPUT_VAR VAR_OUTPUT declared but never written
OUTPUT_VAR_READ_INTERNALLY VAR_OUTPUT read inside the same POU
NESTED_COMMENTS (* outer (* nested *) *)
NAMING_CONVENTION team-configured prefix / suffix / pattern violation
DIRECT_ADDRESS_USED %I0.0 / %QW100-style direct I/O addresses (PLCopen N1 / CP1)
IF_WITHOUT_ELSE IF / ELSIF chain with no terminal ELSE (PLCopen L17)
FORBIDDEN_STATEMENT GOTO / EXIT / CONTINUE use sites (PLCopen L10)
POU_NOT_COMMENTED POU declaration with no leading comment (PLCopen C2)
NAME_REUSED_DIFFERENT_KIND same name reused across declaration kinds (PLCopen N9)
INDIRECT_RECURSIVE_CALL call-graph cycle through two or more POUs (PLCopen CP13)
IDENTIFIER_TOO_LONG identifier longer than limits.max_identifier_length (PLCopen N6)
IDENTIFIER_CHARSET identifier outside identifier_charset regex (PLCopen N8)
TOO_MANY_PARAMETERS POU parameter count above limits.max_parameters (PLCopen CP23)
TOO_MANY_GLOBALS_USED POU touches more than limits.max_globals_used_per_pou globals (PLCopen CP18)
EXTERNAL_VAR_IN_FUNCTION VAR_EXTERNAL inside FUNCTION / FUNCTION_BLOCK / METHOD (PLCopen CP6)
IMPLICIT_TYPE_CONVERSION INT-family mixed with REAL-family in arithmetic (PLCopen CP25)
Security / IEC 62443 (5)
HARDCODED_CREDENTIALS secret-named var with a literal STRING initialiser
HARDCODED_NETWORK_ENDPOINT STRING literal that's an IPv4 / opc.tcp:// / mqtt:// / modbus:// URL
UNVALIDATED_INPUT_USE VAR_INPUT used as array index / divisor with no in-POU guard
DEBUG_PRAGMA_IN_PRODUCTION {attribute 'monitoring'} / 'debug' / 'force_init' in non-test path
PERSISTENT_PLAINTEXT_SECRET VAR_GLOBAL PERSISTENT/RETAIN of a secret-named variable

The Checks reference has a dedicated page per category, with an ST code example and a suggested fix.

Try it in 60 seconds

GitHub

Drop this into .github/workflows/plc-st-review.yml:

name: PLC ST review
on:
  pull_request:
    paths: ['**/*.st', '**/*.ST']
permissions:
  contents: read
  pull-requests: write
jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: HeytalePazguato/plc-st-review@v0

Open any PR that touches a .st file. The bot posts inline review comments within ~30 s.

GitLab

Drop this into .gitlab-ci.yml:

plc-st-review:
  image: ghcr.io/heytalepazguato/plc-st-review:v0
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  variables:
    GITLAB_TOKEN: $CI_JOB_TOKEN
    GITLAB_URL: $CI_SERVER_URL
    GITLAB_PROJECT_ID: $CI_PROJECT_ID
  script:
    - plc-st-review --gitlab --mr "$CI_MERGE_REQUEST_IID"

Works on gitlab.com and self-hosted instances. The image is public on GHCR, no docker login required.

Static linter (no PR workflow required)

lint-st:
  image: ghcr.io/heytalepazguato/plc-st-review:v0
  script:
    - plc-st-review --lint "src/**/*.st"

Runs on every push, fails the pipeline on findings at or above the reporting.fail_on_severity threshold. The 17 diff-based categories auto-disable; the other 35 fire as a static linter.

Local

npm install -g plc-st-review
plc-st-review --lint "src/**/*.st"           # static lint
plc-st-review --base main --head HEAD        # diff current branch
plc-st-review --files old.st new.st          # compare two files

What's next