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 all 52 categories firing on a single PR, with the exact inline comments the bot posts. No mock-ups, no edited screenshots.

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". 35 single-revision checks for ST bugs (division by zero, infinite loops, timer / counter misuse, output-var reads, unused vars, naming conventions, forbidden symbols, more). No PR workflow required.
  • PR / MR reviewer โ€” posts inline review comments anchored to the lines that triggered findings. Adds 17 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 52 check categories

Category What fires it
Diff-based (17) โ€” 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
Static integrity (6) โ€” 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
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 (20)
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

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