plc-st-review
v1.0.3
Published
Semantic linter, code reviewer, and team-style enforcer for IEC 61131-3 Structured Text. Runs as a CI linter, GitHub Action, GitLab CI job, or local CLI. 80 checks (incl. PLCopen and IEC 62443 sets) plus a --metrics mode (complexity, nesting, call graph,
Maintainers
Readme
plc-st-review
A 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. Parses .st files with the tree-sitter-iec61131-3-st grammar and reports semantic problems, not textual diffs.
plc-st-review runs in three modes, each backed by the same 80-check engine:
- Static linter (
--lint src/**/*.st), run on every push. 59 single-revision checks for ST bugs: division by zero, out-of-range array indices, infinite loops, TON/CTU/R_TRIG misuse, output reads, unused vars, naming-convention drift,forbidden_symbols, the full PLCopen-aligned set (forbidden statements, IF without ELSE, direct-address use, pointer arithmetic, parameter/global caps, …), an IEC 62443 cybersecurity set (hard-coded credentials / endpoints, unvalidated input, debug pragmas, persistent plaintext secrets), and more. - PR / MR reviewer (GitHub Action or GitLab CI job), posts inline review comments on lines that triggered findings. Adds 21 diff-based checks that compare the PR against its base: signature drift, outdated call sites, enum removals, timer-value changes, EXTENDS swaps, pragmas,
SAFETY_*constant changes, and metric regressions (complexity, nesting, LOC growth). With--project-scopeit also flags newly added POUs that nothing in the repo calls. - Team-style enforcer: drop a
.plc-st-review.ymlin the repo root listing yournaming_conventions(prefix / suffix / pattern per declaration kind) andforbidden_symbols. Both modes pick it up automatically.
Catches the bugs reviewers miss on visual scan:
- A
TON.PTchanged fromT#5stoT#500ms(10× faster). - A function block grew a required input but only some call sites updated.
- A
CASEon an enum that gained a value but noELSEbranch. - A global variable's type silently changed and the readers are now broken.
- A constant whose name starts with
SAFETY_had its value changed.
See it in action
Live demo: PR #1, every check the tool ships with, posted on a real PR 👈 open this for the full bot output. The PR exercises the 78 always-on check categories, the ones that run on every PR: each shows up as an inline review comment on the changed line that triggered it, and findings on lines outside the PR's diff hunks (e.g. POU_DELETED, or a check whose anchor line wasn't itself edited) collect in a single summary comment at the bottom. Two categories — DEAD_POU_INTRODUCED and MULTI_WRITER_GLOBAL — are opt-in: they need a whole-repo parse, which is too slow to run on every PR, so they're off by default and triggered on demand (see project scope). The PR is intentionally kept open as a fixture; conversation is locked.
A single finding looks like this in the GitHub UI:
🟧 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.
Reading e.g. `instance.Q` without calling `instance(...)`
returns stale data.A handful more of what's posted on PR #1:
FB_ConveyorState.st:26 🟥 error TIMER_VALUE_CHANGED
Timer T_StartupDelay.PT: T#2s → T#200ms (10.0x faster)
Globals.st:9 🟧 warn CONSTANT_VALUE_CHANGED
Constant SAFETY_TIMEOUT: T#2s → T#10s
Identifier prefix matches a safety-critical pattern.
FB_Diagnostics.st:49 🟥 error ARRAY_INDEX_OUT_OF_BOUNDS
arr[15] is out of declared bounds [0..9]
FB_Diagnostics.st:60 🟥 error INFINITE_LOOP
WHILE TRUE loop with no EXIT statementQuick start
CI linter (no PR required)
Most industrial ST repos don't run on a PR/MR workflow, code lands on main after a manual code review and an IDE-side build. You can still get every single-revision check on every push:
# .gitlab-ci.yml, lint every .st file on every push, no MR needed
lint-st:
image: ghcr.io/heytalepazguato/plc-st-review:v0
script:
- plc-st-review --lint "src/**/*.st"# .github/workflows/lint.yml, same idea on GitHub
name: lint
on: [push]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx plc-st-review --lint "src/**/*.st"--lint accepts file paths, directories, or globs (*, **, mixed). It parses each .st file in isolation and runs the 59 single-revision checks: the 21 diff-based ones are auto-disabled because there's no "before" state. Exit code is non-zero when any finding meets reporting.fail_on_severity (default error), so the job fails the pipeline on real bugs.
GitHub pull request
Drop this into .github/workflows/plc-st-review.yml (full example at examples/github-workflow.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@v0The action posts findings as inline review comments. Re-runs update existing comments rather than duplicating.
GitLab merge request
Drop this into .gitlab-ci.yml (full example at examples/gitlab-ci.yml):
plc-st-review:
image: ghcr.io/heytalepazguato/plc-st-review:v0 # or :1.0.0 to pin an exact version
stage: review
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"The job fetches the MR's changed .st files, runs the review, posts findings as inline discussions, and updates them (rather than creating duplicates) on re-runs.
Important notes for GitLab users:
- Pull credentials: GitLab runners pull the image from GHCR anonymously. The image is published as public, so no
docker loginstep is needed. If your runner is offline and you mirror the image internally, pullghcr.io/heytalepazguato/plc-st-review:v0once and push to your internal registry. - Self-hosted GitLab: supported. The example above reads
GITLAB_URLfrom$CI_SERVER_URL, which is auto-provided by every GitLab runner regardless of whether you're ongitlab.comor your own instance. - Token scope:
$CI_JOB_TOKENworks for most projects with default settings. If your instance restricts job tokens, mint a project access token withapiscope and set it as a masked, protected CI/CD variable namedGITLAB_TOKEN, then drop theGITLAB_TOKEN: $CI_JOB_TOKENline. - Same engine as the GitHub Action: every check listed below fires identically on GitLab. There is no GitHub-only path in the engine.
See docs/gitlab-setup.md for the full walkthrough and the common-gotchas list.
CLI
Published on npm:
npm install -g plc-st-review # https://www.npmjs.com/package/plc-st-review
plc-st-review --lint "src/**/*.st" # static linting, no PR / base ref needed
plc-st-review --base main --head HEAD # diff current branch against main
plc-st-review --files old.st new.st # compare two specific files
plc-st-review --base main --output json --out-file findings.jsonOutput formats: terminal (ANSI when stdout is a TTY), markdown, json.
The CLI exits non-zero when at least one finding meets or exceeds the reporting.fail_on_severity threshold (default error).
--max-file-size <bytes> overrides the per-file source-length cap (see Source-size cap below). Useful when you have legitimately huge generated FB files; pass 0 to disable the cap entirely. Defaults to the config's parsing.max_file_size_bytes (1 MB).
Metrics
--metrics is a standalone mode that measures a whole codebase instead of reviewing a diff: cyclomatic complexity, nesting depth, LOC, call-graph fan-in/out, dead code, dependency depth, and more. It does not run the review checks. Full reference: docs/metrics-mode.md.
plc-st-review --metrics src/ # per-POU + project report
plc-st-review --metrics src/ --sort complexity --top 20 # worst 20 POUs
plc-st-review --metrics src/ --threshold complexity=25 # exit nonzero past the bar
plc-st-review --metrics src/ --format json # machine-readable
plc-st-review --metrics src/ --format dot | dot -Tsvg -o deps.svg # call graph
plc-st-review --metrics src/ --format badge # shields.io URLRunning it on the bundled examples/state-machine/ fixtures prints a ranked report (🟢/🟡/🔴 against the configured thresholds):
Project: examples/state-machine/ (11 POUs, 240 LOC)
Top 10 by complexity:
FB_LineController complexity: 26 nesting: 5 LOC: 86 🔴
FB_ConveyorState complexity: 10 nesting: 2 LOC: 42 🟢
Conveyor_HMI complexity: 6 nesting: 1 LOC: 16 🟢
FB_AxisRamp complexity: 5 nesting: 1 LOC: 27 🟢
FB_SpeedCalc complexity: 2 nesting: 1 LOC: 18 🟢
FB_Base complexity: 1 nesting: 0 LOC: 6 🟢
FB_BaseV2 complexity: 1 nesting: 0 LOC: 8 🟢
FB_DiagUnit complexity: 1 nesting: 0 LOC: 10 🟢
FB_Legacy complexity: 1 nesting: 0 LOC: 9 🟢
FB_MetricsDemo complexity: 1 nesting: 0 LOC: 9 🟢
Dead code:
FB_Legacy (0 callers)
FB_Watchdog (0 callers)
Summary:
Avg complexity: 5 Avg nesting: 0.9 Doc coverage: 9.1% Dependency depth: 3
🔴 1 POUs exceed complexity threshold (25)FB_LineController lands in the red band (complexity 26, past the error threshold of 25), and the dead-code list is short and pointed: two blocks nothing calls. A rendered call graph of the same fixtures is in docs/screenshots/metrics-example.svg.
--format dot emits the call graph (pipe to Graphviz); --format json emits the full per-POU + project report for dashboards or CI gates.
Checks
Each row links to a per-check section in docs/checks-reference.md with a ST code example and a suggested fix. See also docs/check-limitations.md for what each check deliberately doesn't catch.
Diff-based (compare before vs after)
| Category | Default severity | Trigger |
|---|---|---|
| SIGNATURE_CHANGED | warn (error on breaking) | A POU's inputs/outputs/in-outs changed. |
| CALL_SITE_OUTDATED | error | A caller doesn't pass a required argument the callee now requires, or passes an unknown argument. |
| TYPE_MISMATCH | error | A VAR_GLOBAL's declared type changed between revisions. |
| ENUM_VALUE_REMOVED | error | A CASE statement references an enum value that was removed. |
| ENUM_VALUE_ADDED | warn | An enum gained a value but a CASE on the enum has no matching branch and no ELSE. |
| TIMER_VALUE_CHANGED | info/warn/error by ratio | TON/TOF/TP PT changed; severity scales with the change magnitude (≥2× = warn, ≥10× = error). |
| CONSTANT_VALUE_CHANGED | info (warn for safety-prefixed names) | A VAR_GLOBAL CONSTANT's initial value changed. Prefixes like SAFETY_, INTERLOCK_, SIL_ elevate severity. |
| COMMENT_ONLY | info | The AST is structurally identical between revisions; only comments/whitespace changed. |
| ARRAY_BOUNDS_CHANGED | error (shrink) / warn (grow) | An array declaration's [lower..upper] bounds changed. |
| LOOP_BOUNDS_CHANGED | info/warn by ratio | A FOR loop's bounds changed; severity rises when the iteration count moves ≥10×. |
| POU_DELETED | error (with callers) / warn | A POU was deleted; severity depends on whether call sites in the new revision still reference it. |
| POU_RENAMED | info | Heuristic: a POU was deleted and another with an identical signature was added; suggests a rename. |
| METHOD_ADDED_TO_INTERFACE | error | An INTERFACE gained a method but a FUNCTION_BLOCK that IMPLEMENTS it doesn't have one. |
| INHERITANCE_CHANGED | warn | An EXTENDS clause was added, removed, or changed. |
| PRAGMA_CHANGED | info | The set of pragmas in a file changed (added or removed). |
| UNUSED_VAR_INTRODUCED | info | A new local variable was declared but isn't referenced in its scope. |
| COUNTER_VALUE_CHANGED | info / warn / error by ratio | CTU/CTD/CTUD PV changed; severity scales with the change magnitude. |
| COMPLEXITY_INCREASED | warn (error on crossing threshold) | A POU's cyclomatic complexity rose by more than 5, or crossed the configured error threshold. |
| NESTING_INCREASED | warn (error on crossing threshold) | A POU's max control-structure nesting depth rose beyond the configured warn threshold. |
| LOC_SPIKE | info | A POU's lines of code grew by more than 50% in a single PR. |
| DEAD_POU_INTRODUCED | info | A newly added FUNCTION/FUNCTION_BLOCK that nothing in the project calls. Needs --project-scope. |
Static (look at the new revision in isolation, filter to bugs new in this PR)
Code-quality + style:
| Category | Default severity | Trigger |
|---|---|---|
| EMPTY_STATEMENT | info | Lone ; with nothing in front. |
| UNUSED_RETURN_VALUE | info | Function called as a bare statement; return discarded. |
| ARRAY_SINGLE_ELEMENT | info | ARRAY [5..5] OF T: single-element array. |
| VARIABLE_SHADOWING | warn | Local declaration has the same name as a VAR_GLOBAL. |
| UNQUALIFIED_ENUM_CONSTANT | info | Bare IDLE matches a member of exactly one enum. |
| IDENTIFIER_CASE_MISMATCH | warn | Reference uses different case than declaration. |
| UNUSED_INPUT_VAR | info | VAR_INPUT declared but never read. |
| INPUT_VAR_WRITTEN | warn | VAR_INPUT is assigned inside the POU. |
| BOOL_COMPARISON | info | IF b = TRUE THEN: comparison adds no information. |
| REAL_EQUALITY | warn | =/<> against a REAL literal, unreliable on floats. |
| MULTIPLE_EXIT_POINTS | info | POU has more than one RETURN. |
| ASSIGNMENT_IN_CONDITION | warn | IF x := y THEN: almost always a typo of =. |
| COMMENTED_OUT_CODE | info | Comment whose content looks like ST source. |
| RECURSIVE_CALL | warn | POU invokes itself; risks stack overflow on bounded runtimes. |
| FORBIDDEN_SYMBOL | error (config-driven) | Identifier matches the repo's forbidden_symbols blocklist. |
| ADDRESS_OF_CONSTANT | warn | ADR(c) where c is a VAR_GLOBAL CONSTANT. |
| UNUSED_OUTPUT_VAR | info | VAR_OUTPUT declared but never written. |
| OUTPUT_VAR_READ_INTERNALLY | info | VAR_OUTPUT read inside the POU; usually a sign you wanted a local. |
| NESTED_COMMENTS | info | Block comment contains another block comment. |
| NAMING_CONVENTION | warn (config-driven) | Declaration name doesn't match the configured prefix / suffix / regex. See docs/preset-packs.md. |
| DIRECT_ADDRESS_USED | info | %I0.0 / %QW100-style direct I/O address (PLCopen N1 / CP1). |
| IF_WITHOUT_ELSE | info | IF / ELSIF chain with no terminal ELSE (PLCopen L17). |
| FORBIDDEN_STATEMENT | info | GOTO / EXIT / CONTINUE use site (PLCopen L10). |
| POU_NOT_COMMENTED | warn | POU declaration with no leading comment (PLCopen C2). |
| NAME_REUSED_DIFFERENT_KIND | warn | Same name reused across declaration kinds, e.g. a global and an enum value (PLCopen N9). |
| INDIRECT_RECURSIVE_CALL | error | Call-graph cycle through two or more POUs (PLCopen CP13 indirect). |
| IDENTIFIER_TOO_LONG | warn | Identifier longer than limits.max_identifier_length (PLCopen N6). |
| IDENTIFIER_CHARSET | warn | Identifier outside the identifier_charset regex (PLCopen N8). |
| TOO_MANY_PARAMETERS | warn | POU parameter count above limits.max_parameters (PLCopen CP23). |
| TOO_MANY_GLOBALS_USED | warn | POU touches more than limits.max_globals_used_per_pou globals (PLCopen CP18). |
| EXTERNAL_VAR_IN_FUNCTION | warn | VAR_EXTERNAL inside FUNCTION / FUNCTION_BLOCK / METHOD (PLCopen CP6). |
| IMPLICIT_TYPE_CONVERSION | warn | INT-family mixed with REAL-family in arithmetic (PLCopen CP25). |
Security (IEC 62443 industrial-cybersecurity checks):
| Category | Default severity | Trigger |
|---|---|---|
| HARDCODED_CREDENTIALS | error | Secret-named variable with a literal STRING initialiser (62443-4-2 CR 1.5). |
| HARDCODED_NETWORK_ENDPOINT | warn | STRING literal that's an IPv4 dotted-quad or http(s)/tcp/opc.tcp/mqtt(s)/modbus/ssh URL (62443-4-1 SI-1). |
| UNVALIDATED_INPUT_USE | info | VAR_INPUT used as array subscript or divisor with no in-POU relational guard (62443-4-2 CR 3.5). |
| DEBUG_PRAGMA_IN_PRODUCTION | warn | {attribute 'monitoring'} / 'debug' / 'force_init' pragma in a non-test path (62443-4-1 SI-2). |
| PERSISTENT_PLAINTEXT_SECRET | error | VAR_GLOBAL PERSISTENT / RETAIN of a secret-named variable (62443-4-2 CR 4.1). |
See the IEC 62443 mapping page for the per-clause cross-reference and suggested severity policy by Security Level. The companion MISRA-C and IEC 61508 mapping pages cover the rest of the standards-credibility surface.
Single-revision integrity:
| Category | Default severity | Trigger |
|---|---|---|
| ENUM_VALUE_UNUSED | info | An enum value is declared but no longer referenced anywhere in the repo. |
| ENUM_MEMBER_UNKNOWN | error | A qualified ref like E_State.IDEL doesn't match any declared member of E_State: likely a typo. |
| ARRAY_INDEX_OUT_OF_BOUNDS | error | A literal index sits outside the array's declared bounds (arr[15] when arr is ARRAY [0..9]). |
| DIVISION_BY_ZERO | error | The divisor is a literal 0, or a VAR_GLOBAL CONSTANT resolving to 0. |
| INFINITE_LOOP | error | WHILE TRUE DO ... END_WHILE; with no EXIT inside the body. |
| LOOP_BOUNDS_REVERSED | error | FOR loop bounds and step point opposite directions, per spec body never runs, on overflow-wrapping runtimes it runs ~unlimited times. |
| STATE_UNHANDLED | info | A CASE on an enum has no ELSE and doesn't cover every enum value, regardless of whether the enum changed. |
| UNREACHABLE_CODE | warn | A statement sits after RETURN / EXIT / CONTINUE in the same block. |
| FOR_LOOP_VAR_MODIFIED | warn | Loop counter mutated inside the FOR body (PLCopen L12). |
| FOR_LOOP_VAR_USED_AFTER | info | Loop counter read after END_FOR — the post-loop value is implementation-defined (PLCopen L13). |
| POINTER_ARITHMETIC | warn | + / - / * / / applied to a POINTER-typed local (PLCopen E2). |
| POINTER_COMPARED | warn | < / > / <= / >= applied to a POINTER-typed local (PLCopen E3). |
| UNINITIALIZED_VAR_USED | warn | Local variable read before any assignment to it (PLCopen CP3). |
| TIME_EQUALITY | warn | = / <> between TIME operands; brittle on tick-based runtimes (PLCopen CP28). |
| MULTI_WRITER_GLOBAL | error | A global variable is written by more than one PROGRAM. Needs --project-scope (PLCopen CP26). |
| COUNTER_PV_ZERO | error | CTU/CTD/CTUD initialized with PV := 0: Q always TRUE or counter useless. |
| TIMER_PT_ZERO | error | TON/TOF/TP set with PT := T#0s: fires immediately or never. |
| TIMER_NOT_DRIVEN | warn | A timer's Q/ET is read but no call site sets IN. |
| EDGE_TRIG_REUSED | error | Same R_TRIG/F_TRIG instance fed by multiple different CLK expressions. |
| FB_INSTANCE_DOUBLE_CALL | warn | Same FB instance invoked more than once in one scope's scan. |
| FB_INSTANCE_NEVER_CALLED | warn | FB instance declared, its outputs read, but no call site invokes it. |
| BISTABLE_DOMINANCE_MISMATCH | info | SR/RS choice mismatches the variable name's intent (heuristic). |
Configuration
Create .plc-st-review.yml at the repo root:
case_sensitive: false # identifier casing rules; see "Case sensitivity" below
disabled_checks:
- COMMENT_ONLY
severity_overrides:
TIMER_VALUE_CHANGED: error # all timer changes block merge
ignore_paths:
- "deprecated/**"
- "third_party/**"
safety_critical_prefixes:
- SAFETY_
- INTERLOCK_
- SIL_
reporting:
fail_on_severity: error # exit-nonzero threshold
comment_style: inline # inline | summary | both (GitLab/GitHub)
metrics: # bands for the metric-regression checks
thresholds:
cyclomatic_complexity:
warn: 15
error: 25
nesting_depth:
warn: 5
error: 8
parsing:
max_file_size_bytes: 1000000 # per-file size cap; see "Source-size cap" below
limits: # numeric caps for the size-/count-checking PLCopen rules
max_identifier_length: 32 # PLCopen N6 — drives IDENTIFIER_TOO_LONG (null = off)
max_globals_used_per_pou: 10 # PLCopen CP18 — drives TOO_MANY_GLOBALS_USED (null = off)
max_parameters: 8 # PLCopen CP23 — drives TOO_MANY_PARAMETERS (null = off)
identifier_charset: null # PLCopen N8 — regex every identifier must match; null = off
# e.g. '^[A-Za-z_][A-Za-z0-9_]*$' restricts to ASCII + underscoreThe metrics block is optional; the values shown are the defaults. COMPLEXITY_INCREASED and NESTING_INCREASED read these bands; LOC_SPIKE fires on any single-PR growth over 50%. See docs/checks-reference.md for the full block (the lines_of_code, comment_ratio, and fan_out keys are accepted now and consumed by the upcoming standalone --metrics mode).
Case sensitivity
Whether two identifiers that differ only in case (Motor vs motor) are the same symbol depends on your toolchain, so plc-st-review makes it configurable with one top-level key:
case_sensitive: false # defaultfalse(default) — identifiers are matched case-insensitively. This is what the IEC 61131-3 standard specifies and what CODESYS (and CODESYS-derived IDEs) and Beckhoff TwinCAT do. With this setting the engine resolves a constant declaredMaxCounteven when it is referenced asMAXCOUNT, andIDENTIFIER_CASE_MISMATCHreports references whose casing drifts from the declaration.true— identifiers are matched exactly, byte-for-byte. Set this for B&R Automation Studio, which treatsMotorandmotoras two distinct variables. In this modeIDENTIFIER_CASE_MISMATCHis automatically disabled (a different case is a different symbol, not a style slip), and checks likeVARIABLE_SHADOWINGonly fire on an exact-case match.
Pick the value that matches the IDE your code is compiled in; the wrong setting can hide real bugs (too loose) or invent false ones (too strict).
Source-size cap
Each .st file is parsed by the tree-sitter grammar running as WebAssembly. To keep a single pathological or hostile file from blowing up memory in the parser, the engine enforces a per-file source-length cap. Files over the cap are skipped with a one-line stderr warning naming the path and treated as empty by every check.
parsing:
max_file_size_bytes: 1000000 # default: 1 MBSet 0 to disable the cap entirely (every file is parsed regardless of size). A --max-file-size <bytes> CLI flag overrides whatever is in the config for the current run, so you can do:
plc-st-review --lint "src/**/*.st" --max-file-size 0 # parse everything, no cap
plc-st-review --lint "src/**/*.st" --max-file-size 5000000 # raise the cap to 5 MB for this runAlmost every real ST file is far below the default; raise this only if you have legitimately huge generated FB files. Disable it only if you trust every file in scope (e.g. linting your own first-party code), since the cap is what stops a hostile file from exhausting parser memory.
PLCopen Coding Guidelines preset
The repo ships one opinionated preset: presets/plcopen.yml, tuned to PLCopen Coding Guidelines v1.0. Vendor-neutral, standards-body-published — use it as your baseline if your team doesn't already have a style guide:
# .plc-st-review.yml
extends:
- ./presets/plcopen.yml
# Layer your own preferences on top.
naming_conventions:
bool: { prefix: b } # we use `b`, not PLCopen's `x`The full rule-by-rule mapping (which PLCopen rules the preset enforces, which would need new checks, which are out of scope) is on the Pages site under Presets → PLCopen Coding Guidelines.
How it works
Every change is reduced to an AST diff. The engine:
- Parses the
beforeandafterversions of every changed.stfile with the tree-sitter grammar. - Builds a symbol table per revision: POUs (with parameter signatures), global variables, enums, timer instances, call sites,
CASEstatements. - Hands both tables to each registered check. Each check is a self-contained module under
src/engine/checks/. - Renders the resulting findings to terminal / Markdown / JSON.
No LLM is involved. Findings are deterministic.
Deterministic by design — no LLM in the loop
Every finding is produced by a check module under src/engine/checks/ that operates on the tree-sitter AST and the symbol table. Each module is a pure function of (before AST, after AST, config) → findings[]. Same inputs, same outputs, byte for byte. No temperature, no sampling, no model version drift.
That choice buys four properties an LLM-based reviewer can't match:
- Reproducible — a finding that fires today fires tomorrow on the same input. CI reruns match. Two engineers see the same comments. Findings stay citable in code review and postmortems six months later.
- Auditable — every finding maps to one named check with source code you can read. For regulated industries (medical, automotive, energy, pharma) this auditability is the difference between "evidence we can show an inspector" and "a black box we trust."
- Air-gappable — the shipped Docker image bundles everything the analyzer needs. Mirrored to an internal registry, it runs on offline runners with zero outbound traffic — no model-host endpoints, no API tokens, no SaaS dependency.
- Your code never leaves the network — parsing and analysis run in-process in the CI job. Nothing is sent to a third party. There's no "we promise we don't train on your code" disclaimer to evaluate; the architecture makes it impossible.
PLC source typically encodes safety logic, vendor part numbers, calibration constants, customer-specific behavior, and timing assumptions that the customer never agreed to have anywhere but the runner that built it. On that kind of code, deterministic wins by default. See Deterministic (no LLM) for the longer write-up.
Development
Contributors only. End users don't need this section: the GitHub Action, GitLab CI image, and npm CLI are all ready to use without building from source.
npm install # plain install — nothing compiles
npm run build # tsc to dist/
npm test # vitest, ~7s, 274 tests across 70 files
npm run lint # tsc --noEmitBuilding from source
npm installThat's the whole setup. The parser ships as WebAssembly: web-tree-sitter boots the tree-sitter runtime and loads the grammar's tree-sitter-iec61131_3_st.wasm (published inside the tree-sitter-iec61131-3-st package). There's no native addon to compile, so no C++ toolchain, no node-gyp, and no platform-specific prerequisites — npm install runs the same on Windows, Linux, and macOS, on any supported Node version.
Roadmap
Likely additions, in rough priority order:
- Standalone CLI binaries:
plc-st-review-linux-x64,-darwin-arm64, etc. as GitHub Release assets, for shops that don't have Node installed. More tractable now that the parser is a single platform-independent.wasmrather than per-platform.nodeaddons — the remaining work is bundling the two wasm files (the grammar's andweb-tree-sitter's runtime) alongside the packed binary. Revisit if real users without Node ask. - Optional LLM-powered explanations: a
--explainflag that paraphrases deterministic findings in plain English for less-experienced reviewers. Strictly additive, every explanation is grounded in a deterministic finding; the LLM never surfaces new issues. - Vendor-specific checks: PLCopen
MC_*motion patterns, TwinCAT / CODESYS / ABB-specific library FBs. Currently the engine sticks to standard IEC 61131-3 to stay portable across vendors.
License
MIT, see LICENSE.
