A CODESYS project builds with zero errors — and ships with an uncalled timer, a truncated setpoint, and a divide by a variable that's sometimes zero. The syntax is valid; the behavior isn't. Here's what static analysis finds where the compiler stays silent.
8real boiler-house exports
62–86score out of 100
~10 secfrom upload to report
0lines stored on disk
You inherited the project
Someone else wrote it, the documentation is a folder of PDFs, and the person who knew why that IF is there has left the company. You sign the acceptance act anyway.
What the code actually does — POUs, tasks, devices and I/O bindings on one page
Where it will bite: uncalled timers, unreachable branches, division by a variable that is sometimes zero
What is dead weight — POUs nothing calls, copy-pasted loops, leftovers from the previous site
No NDA argument to have with yourself here: the code isn't yours, nothing is written to disk, and the report takes ten seconds. Checking your own project before handover works exactly the same.
Findings — by severity
uncalled-timerhigh · critical
Uncalled timer
VAR
Pause_pump : TOF;
END_VAR// .Q is used in the pump-start logic…IF NOT Pause_pump.Q THEN Start_Pump(); END_IF// …but the block is never called — the timer never ticks,// Pause_pump.Q stays at its default forever.
✓Compiler: builds⚠PLC Lint: caught
The block is declared and read, but never called — the inter-start pause simply doesn't work. Found on a real boiler-house project.
real-to-time-mulmedium · warning
Setpoint truncated by REAL_TO_TIME
tPulse := REAL_TO_TIME(rSetpoint) * 10;// REAL_TO_TIME treats the number as milliseconds and// truncates BEFORE the multiply — the setpoint is gone.
✓Compiler: builds⚠PLC Lint: caught
The classic “converting seconds to ms”. The multiply can't save it — the fraction is lost during the conversion.
div-by-zerohigh · critical
Divide by a zero variable
rAvg := rSum / iCount;
// iCount is 0 on an empty sample → runtime exception// on site, not on the bench.
✓Compiler: builds⚠PLC Lint: caught
The linter distinguishes a literal 0 divisor, a variable that's never assigned nonzero, or one assigned 0 with no later <> 0 guard. Type-aware: plain REAL division is not flagged — false positives were killed on purpose.
unreachable-branchhigh · critical
Unreachable IF/ELSIF branch
IF x >= 100 THEN
Alarm_Low();
ELSIF x >= 120 THEN// unreachable: >=120 already caught above
Alarm_High();
END_IF
✓Compiler: builds⚠PLC Lint: caught
The thresholds are in the wrong order — the high alarm never fires. The rule compares the full right-hand operand, so it won't confuse this with real hysteresis (T + 0.02).
watchdog-disabledmedium · warning
Task watchdog disabled
<TaskSettings KindOfTask="Cyclic" Interval="t#10ms"><Watchdog Enabled="false" /> <!-- on EVERY task --></TaskSettings>
✓Compiler: builds⚠PLC Lint: caught
Not a bug per se — but for a critical process a stalled scan won't get restarted. Better to see it in the summary than discover it on site.
The actual report — not a mockup
Real output on a synthetic project with planted bugs. It's the same report you get for your own export — score, severity, per-rule breakdown, and every finding with a concrete fix.
The person who signs the acceptance act cannot read the code
A quality report is written for the engineer who wrote the code. But the money and the real fear sit with the party accepting the site: they sign for something they cannot read, and “uncalled timer at Main:214” tells them nothing. So the same analysis has a second face — a list of questions to ask the contractor, in plain language, with no code and no line numbers. Print it, attach it to the act, get the answers in writing.
Two or more modules share one bus address — which one answers the poll?
A task has its watchdog disabled — who restarts it if the scan overruns?
There are conditions that will never be met at any temperature — what behaviour was intended?
A timer is declared and its output is used, but the timer is never started — does the delay work?
Grouped by what it costs you: answer before signing / clarify during acceptance / notes for maintenance. Built from the same report — nothing is stored on the server.
Boiler-house / heat-point profileAn opt-in rule for analog values that are never range-checked anywhere in the project: a broken Pt1000 reads as a valid 0 °C and the valve opens fully. One rule, not six — the other five were dropped after measuring them on nine real exports.
Industry benchmark“48 out of 100” means nothing; “48 against a median of 62” means everything. Published only once the sample is large enough — an average over a dozen files is not an average.
Second opinion before commissioningThe automatic report plus a written verdict from an engineer on which findings are actually dangerous on your site. Answer within 24 hours.
What PLC Lint checks
30 checks across code, structure and config. A sample of what it flags:
A name that's never declared — typos in gvl.*
Timers/triggers that are read but never called
Division by zero — a literal, or a variable that's only ever 0
Unreachable IF/ELSIF branches (thresholds in the wrong order)
Dead code — POUs nothing ever calls
Duplicated logic — renamed copy-paste control loops
Setpoints truncated by REAL_TO_TIME(...)*N
Array indexes outside the declared bounds
Disabled task watchdogs & missing runtime checks
Magic numbers, missing comments, bloated routines
Device / I-O map: controller, modules, buses, signal bindings
Analog values that are never range-checked (boiler-house profile, opt-in)
Upload a .xml from CODESYS (Project → Export PLCopenXML), TwinCAT3, WAGO or ОВЕН. Report in ~10 seconds. Free, no signup for the first analysis, nothing stored.