{
  "manifest": "oip-web ST capabilities",
  "version": 1,
  "generatedBy": "npm run capabilities -- --write",
  "generatedFrom": "the engine itself: every verdict below is the engine in src/plc (checker.ts and interpreter.ts) answering, not prose transcribed from a doc",
  "language": {
    "name": "IEC 61131-3 Structured Text",
    "status": "a subset, deliberately",
    "claim": "Within the subset, a program behaves the way a real IEC runtime would, so ST written here copies onto a physical PLC. There is no compliance statement and no PLCopen certification.",
    "ledger": "docs/st-conformance.md",
    "page": "https://www.openindustryproject.org/st/"
  },
  "types": {
    "supported": [
      "BOOL",
      "SINT",
      "INT",
      "DINT",
      "USINT",
      "UINT",
      "UDINT",
      "BYTE",
      "WORD",
      "DWORD",
      "REAL",
      "LREAL",
      "TIME",
      "STRING"
    ],
    "defaultStringLength": 80,
    "notes": [
      "REAL is single precision (f32 after every operation), LREAL is 64-bit, as on every target.",
      "TIME carries milliseconds and never mixes with numbers implicitly.",
      "The three integer families (signed, unsigned, bit string) are kept apart at the operator level."
    ]
  },
  "functionBlocks": [
    "TON",
    "TOF",
    "TP",
    "R_TRIG",
    "F_TRIG",
    "CTU",
    "CTD",
    "CTUD",
    "SR",
    "RS"
  ],
  "functions": {
    "named": [
      "ABS",
      "ACOS",
      "ADD",
      "ASIN",
      "ATAN",
      "BCD_TO_INT",
      "CONCAT",
      "COS",
      "DELETE",
      "DIV",
      "EQ",
      "EXP",
      "EXPT",
      "FIND",
      "GE",
      "GT",
      "INSERT",
      "INT_TO_BCD",
      "LE",
      "LEFT",
      "LEN",
      "LIMIT",
      "LN",
      "LOG",
      "LT",
      "MAX",
      "MID",
      "MIN",
      "MOVE",
      "MUL",
      "MUX",
      "NE",
      "REPLACE",
      "RIGHT",
      "ROL",
      "ROR",
      "SEL",
      "SHL",
      "SHR",
      "SIN",
      "SQRT",
      "SUB",
      "TAN",
      "TRUNC"
    ],
    "conversions": {
      "pattern": "<SRC>_TO_<DST>",
      "over": [
        "BOOL",
        "SINT",
        "INT",
        "DINT",
        "USINT",
        "UINT",
        "UDINT",
        "BYTE",
        "WORD",
        "DWORD",
        "REAL",
        "LREAL",
        "TIME",
        "STRING"
      ],
      "verified": [
        "INT_TO_REAL",
        "REAL_TO_INT",
        "STRING_TO_TIME",
        "TIME_TO_DINT"
      ]
    }
  },
  "keywords": [
    "AND",
    "ARRAY",
    "BOOL",
    "BY",
    "BYTE",
    "CASE",
    "CONSTANT",
    "CONTINUE",
    "DINT",
    "DO",
    "DWORD",
    "ELSE",
    "ELSIF",
    "END_CASE",
    "END_FOR",
    "END_FUNCTION",
    "END_FUNCTION_BLOCK",
    "END_IF",
    "END_REPEAT",
    "END_STRUCT",
    "END_TYPE",
    "END_VAR",
    "END_WHILE",
    "EXIT",
    "FALSE",
    "FOR",
    "FUNCTION",
    "FUNCTION_BLOCK",
    "IF",
    "INT",
    "LREAL",
    "MOD",
    "NOT",
    "OF",
    "OR",
    "REAL",
    "REPEAT",
    "RETURN",
    "SINT",
    "STRING",
    "STRUCT",
    "THEN",
    "TIME",
    "TO",
    "TRUE",
    "TYPE",
    "UDINT",
    "UINT",
    "UNTIL",
    "USINT",
    "VAR",
    "VAR_EXTERNAL",
    "VAR_GLOBAL",
    "VAR_INPUT",
    "VAR_IN_OUT",
    "VAR_OUTPUT",
    "VAR_TEMP",
    "WHILE",
    "WORD",
    "XOR"
  ],
  "features": [
    {
      "id": "if",
      "category": "Control flow",
      "intent": "supported",
      "title": "IF / ELSIF / ELSE",
      "st": "VAR_INPUT n : INT; END_VAR VAR_OUTPUT q : INT; END_VAR\nIF n > 10 THEN q := 2; ELSIF n > 0 THEN q := 1; ELSE q := 0; END_IF;",
      "verdict": "compiles"
    },
    {
      "id": "case",
      "category": "Control flow",
      "intent": "supported",
      "title": "CASE with lists and lo..hi ranges",
      "st": "VAR_INPUT n : INT; END_VAR VAR_OUTPUT q : INT; END_VAR\nCASE n OF\n  1, 3: q := 1;\n  4..9: q := 2;\n  -1: q := 9;\nELSE q := 0;\nEND_CASE;",
      "verdict": "compiles"
    },
    {
      "id": "for",
      "category": "Control flow",
      "intent": "supported",
      "title": "FOR with BY",
      "st": "VAR i : INT; sum : INT; END_VAR\nFOR i := 10 TO 0 BY -2 DO sum := sum + i; END_FOR;",
      "verdict": "compiles"
    },
    {
      "id": "while-repeat",
      "category": "Control flow",
      "intent": "supported",
      "title": "WHILE and REPEAT ... UNTIL",
      "st": "VAR i : INT; j : INT; END_VAR\nWHILE i < 5 DO i := i + 1; END_WHILE;\nREPEAT j := j + 1; UNTIL j >= 5 END_REPEAT;",
      "verdict": "compiles"
    },
    {
      "id": "exit-continue-return",
      "category": "Control flow",
      "intent": "supported",
      "title": "EXIT, CONTINUE, RETURN",
      "st": "VAR i : INT; n : INT; END_VAR\nFOR i := 1 TO 10 DO\n  IF i = 3 THEN CONTINUE; END_IF;\n  IF i = 8 THEN EXIT; END_IF;\n  n := n + 1;\nEND_FOR;\nRETURN;",
      "verdict": "compiles"
    },
    {
      "id": "function-block",
      "category": "POUs",
      "intent": "supported",
      "title": "User FUNCTION_BLOCK, named inputs and => output bindings",
      "st": "FUNCTION_BLOCK Motor\nVAR_INPUT Run : BOOL; END_VAR\nVAR_OUTPUT Spinning : BOOL; END_VAR\nSpinning := Run;\nEND_FUNCTION_BLOCK\n\nVAR M1 : Motor; lamp : BOOL; END_VAR\nM1(Run := TRUE, Spinning => lamp);",
      "verdict": "compiles"
    },
    {
      "id": "function",
      "category": "POUs",
      "intent": "supported",
      "title": "Stateless FUNCTION (returns by assigning its own name)",
      "st": "FUNCTION Double : INT\nVAR_INPUT x : INT; END_VAR\nDouble := x * 2;\nEND_FUNCTION\n\nVAR r : INT; END_VAR\nr := Double(21);",
      "verdict": "compiles"
    },
    {
      "id": "fb-shadow",
      "category": "POUs",
      "intent": "supported",
      "title": "A user block may shadow a standard one (TON is not reserved)",
      "st": "FUNCTION_BLOCK TON\nVAR_INPUT IN : BOOL; END_VAR\nVAR_OUTPUT Q : BOOL; END_VAR\nQ := IN;\nEND_FUNCTION_BLOCK\n\nVAR t : TON; q : BOOL; END_VAR\nt(IN := TRUE, Q => q);",
      "verdict": "compiles"
    },
    {
      "id": "positional-fb-args",
      "category": "POUs",
      "intent": "supported",
      "title": "Positional arguments, in the block’s own declaration order",
      "st": "VAR t : TON; END_VAR\nt(TRUE, T#1s);",
      "verdict": "compiles"
    },
    {
      "id": "var-in-out",
      "category": "POUs",
      "intent": "supported",
      "title": "VAR_IN_OUT binds a place, so the block writes the caller’s array",
      "st": "FUNCTION_BLOCK Fill\nVAR_IN_OUT buf : ARRAY [1..4] OF INT; END_VAR\nVAR i : INT; END_VAR\nFOR i := 1 TO 4 DO buf[i] := i; END_FOR;\nEND_FUNCTION_BLOCK\n\nVAR f : Fill; data : ARRAY [1..4] OF INT; END_VAR\nf(buf := data);",
      "verdict": "compiles"
    },
    {
      "id": "fb-array",
      "category": "POUs",
      "intent": "supported",
      "title": "An array of function-block instances, called by index",
      "st": "VAR eyes : ARRAY [1..4] OF BOOL; delay : ARRAY [1..4] OF TON; jam : ARRAY [1..4] OF BOOL; i : INT; END_VAR\nFOR i := 1 TO 4 DO\n  delay[i](IN := eyes[i], PT := T#3s);\n  jam[i] := delay[i].Q;\nEND_FOR;",
      "verdict": "compiles"
    },
    {
      "id": "fb-in-struct",
      "category": "POUs",
      "intent": "supported",
      "title": "A function-block instance as a struct field",
      "st": "TYPE Merge : STRUCT gap : TON; count : CTU; END_STRUCT; END_TYPE\nVAR m : Merge; eye : BOOL; open : BOOL; END_VAR\nm.gap(IN := NOT eye, PT := T#500ms);\nm.count(CU := eye, PV := 100);\nopen := m.gap.Q;",
      "verdict": "compiles"
    },
    {
      "id": "struct-copy",
      "category": "Declarations",
      "intent": "supported",
      "title": "Whole structs and arrays assign as values",
      "st": "TYPE Pkg : STRUCT id : DINT; dest : INT; END_STRUCT; END_TYPE\nVAR a : Pkg; b : Pkg; track : ARRAY [0..3] OF Pkg; i : INT; END_VAR\nb := a;\nFOR i := 3 TO 1 BY -1 DO track[i] := track[i - 1]; END_FOR;",
      "verdict": "compiles"
    },
    {
      "id": "struct-io",
      "category": "POUs",
      "intent": "supported",
      "title": "Structs in and out: by-value inputs, readable outputs, struct results",
      "st": "TYPE Pkg : STRUCT id : DINT; END_STRUCT; END_TYPE\nFUNCTION Make : Pkg\nVAR_INPUT id : DINT; END_VAR\nMake.id := id;\nEND_FUNCTION\n\nFUNCTION_BLOCK Latch\nVAR_INPUT p : Pkg; END_VAR\nVAR_OUTPUT held : Pkg; END_VAR\nheld := p;\nEND_FUNCTION_BLOCK\n\nVAR l : Latch; box : Pkg; copy : Pkg; n : DINT; END_VAR\nbox := Make(7);\nl(p := box, held => copy);\nn := l.held.id;",
      "verdict": "compiles"
    },
    {
      "id": "formal-function-call",
      "category": "POUs",
      "intent": "supported",
      "title": "A formal FUNCTION call, naming its arguments in any order",
      "st": "FUNCTION Span : INT\nVAR_INPUT lo : INT; hi : INT; END_VAR\nSpan := hi - lo;\nEND_FUNCTION\n\nVAR n : INT; END_VAR\nn := Span(hi := 10, lo := 3);",
      "verdict": "compiles"
    },
    {
      "id": "qualified-enum",
      "category": "Declarations",
      "intent": "supported",
      "title": "An enumeration value written with its type (Mode#Idle)",
      "st": "TYPE Mode : (Idle, Running); END_TYPE\nVAR m : Mode := Mode#Idle; END_VAR\nIF m = Mode#Idle THEN m := Mode#Running; END_IF;",
      "verdict": "compiles"
    },
    {
      "id": "power-operator",
      "category": "Library",
      "intent": "supported",
      "title": "** exponentiation (EXPT)",
      "st": "VAR a : REAL; END_VAR\na := 2.0 ** 10.0;",
      "verdict": "compiles"
    },
    {
      "id": "multi-name-decl",
      "category": "Declarations",
      "intent": "supported",
      "title": "Several names on one declaration line",
      "st": "VAR x, y, z : INT := 3; END_VAR\nx := y + z;",
      "verdict": "compiles"
    },
    {
      "id": "constant",
      "category": "Declarations",
      "intent": "supported",
      "title": "CONSTANT",
      "st": "VAR CONSTANT Limit : INT := 100; END_VAR VAR n : INT; END_VAR\nn := Limit;",
      "verdict": "compiles"
    },
    {
      "id": "var-global",
      "category": "Declarations",
      "intent": "supported",
      "title": "VAR_GLOBAL, shared by every POU",
      "st": "VAR_GLOBAL Shared : INT := 5; END_VAR\nVAR n : INT; END_VAR\nn := Shared;",
      "verdict": "compiles"
    },
    {
      "id": "global-fb",
      "category": "Declarations",
      "intent": "supported",
      "title": "A function-block instance in VAR_GLOBAL",
      "st": "VAR_GLOBAL t : TON; END_VAR\nVAR q : BOOL; END_VAR\nt(IN := TRUE, PT := T#1s);\nq := t.Q;",
      "verdict": "compiles"
    },
    {
      "id": "struct",
      "category": "Declarations",
      "intent": "supported",
      "title": "TYPE ... STRUCT, nested access paths",
      "st": "TYPE Inner : STRUCT a : INT; END_STRUCT; END_TYPE\nTYPE Outer : STRUCT\n  inner : Inner;\n  data : ARRAY [1..3] OF INT;\nEND_STRUCT; END_TYPE\nVAR p : Outer; n : INT; END_VAR\nn := p.inner.a + p.data[2];",
      "verdict": "compiles"
    },
    {
      "id": "array",
      "category": "Declarations",
      "intent": "supported",
      "title": "Multi-dimensional ARRAY with a repeated initialiser",
      "st": "VAR grid : ARRAY [1..2, 0..3] OF INT := [1, 2, 6(0)]; n : INT; END_VAR\nn := grid[2, 1];",
      "verdict": "compiles"
    },
    {
      "id": "enum",
      "category": "Declarations",
      "intent": "supported",
      "title": "Enumerations are their own type",
      "st": "TYPE Mode : (Idle, Running := 10, Fault); END_TYPE\nVAR m : Mode := Running; n : INT; END_VAR\nCASE m OF\n  Idle: n := 0;\n  Running: n := 1;\n  Fault: n := 2;\nEND_CASE;",
      "verdict": "compiles"
    },
    {
      "id": "subrange",
      "category": "Declarations",
      "intent": "supported",
      "title": "Subranges, enforced on every store",
      "st": "TYPE Percent : INT (0..100); END_TYPE\nVAR p : Percent := 50; END_VAR\np := 60;",
      "verdict": "compiles"
    },
    {
      "id": "integer-families",
      "category": "Types",
      "intent": "supported",
      "title": "Signed, unsigned and bit-string families, kept apart",
      "st": "VAR\n  si : SINT; i : INT; di : DINT;\n  usi : USINT; ui : UINT; udi : UDINT;\n  b : BYTE; w : WORD; dw : DWORD;\nEND_VAR\ni := i + 1;\nui := ui + 1;\nw := w AND 16#00FF;",
      "verdict": "compiles"
    },
    {
      "id": "real-lreal",
      "category": "Types",
      "intent": "supported",
      "title": "REAL (single precision) and LREAL",
      "st": "VAR r : REAL := 0.4; l : LREAL; END_VAR\nl := r;",
      "verdict": "compiles"
    },
    {
      "id": "time",
      "category": "Types",
      "intent": "supported",
      "title": "TIME is a duration, not a number",
      "st": "VAR t : TIME := T#1m30s; u : TIME; n : DINT; END_VAR\nu := t + T#500ms;\nn := TIME_TO_DINT(u);",
      "verdict": "compiles"
    },
    {
      "id": "string",
      "category": "Types",
      "intent": "supported",
      "title": "STRING with a capacity, and IEC $ escapes",
      "st": "VAR s : STRING[20] := 'line$N'; n : INT; END_VAR\ns := CONCAT(s, 'more');\nn := LEN(s);",
      "verdict": "compiles"
    },
    {
      "id": "string-order",
      "category": "Types",
      "intent": "supported",
      "title": "STRING compares and sorts, but has no +",
      "st": "VAR\n  a : STRING := 'abc';\n  b : STRING := 'abd';\n  lo : STRING; q : BOOL;\nEND_VAR\nq := a < b;\nlo := MIN(a, b);",
      "verdict": "compiles"
    },
    {
      "id": "literals",
      "category": "Types",
      "intent": "supported",
      "title": "Based, typed, exponent and separated literals",
      "st": "VAR\n  h : WORD := 16#FF;\n  bits : BYTE := 2#1010_1010;\n  oct : INT := 8#17;\n  typed : INT := INT#5;\n  r : REAL := 1.0E-3;\nEND_VAR\nh := h OR WORD#16#0F;",
      "verdict": "compiles"
    },
    {
      "id": "bool-0-1",
      "category": "Types",
      "intent": "supported",
      "title": "0 and 1 are BOOL literals (the standard’s own examples use them)",
      "st": "VAR_OUTPUT Q : BOOL := 0; END_VAR\nQ := 1;",
      "verdict": "compiles"
    },
    {
      "id": "ampersand",
      "category": "Types",
      "intent": "supported",
      "title": "& is AND",
      "st": "VAR a : BOOL; b : BOOL; q : BOOL; END_VAR\nq := a & b;",
      "verdict": "compiles"
    },
    {
      "id": "case-insensitive",
      "category": "Types",
      "intent": "supported",
      "title": "Identifiers are case-insensitive",
      "st": "VAR Motor : BOOL; END_VAR\nmotor := TRUE;",
      "verdict": "compiles"
    },
    {
      "id": "comments",
      "category": "Types",
      "intent": "supported",
      "title": "(* ... *) comments, anywhere trivia may go",
      "st": "(* a header comment *)\nVAR n : INT; END_VAR\nn := 1; (* trailing *)",
      "verdict": "compiles"
    },
    {
      "id": "standard-fbs",
      "category": "Library",
      "intent": "supported",
      "title": "Timers, counters and edge detectors",
      "st": "VAR\n  t : TON; f : TOF; p : TP;\n  r : R_TRIG; ft : F_TRIG;\n  cu : CTU; cd : CTD; ud : CTUD;\n  s : SR; rs : RS;\nEND_VAR\nt(IN := TRUE, PT := T#1s);\ncu(CU := t.Q, PV := 10);",
      "verdict": "compiles"
    },
    {
      "id": "conversions",
      "category": "Library",
      "intent": "supported",
      "title": "The <SRC>_TO_<DST> conversion family",
      "st": "VAR i : INT := 7; r : REAL; w : WORD; s : STRING; END_VAR\nr := INT_TO_REAL(i);\nw := INT_TO_WORD(i);\ns := INT_TO_STRING(i);\ni := STRING_TO_INT('42');",
      "verdict": "compiles"
    },
    {
      "id": "comparison-chain",
      "category": "Library",
      "intent": "supported",
      "title": "Functional operator forms, including comparison chains",
      "st": "VAR\n  a : INT := 3; b : INT := 2; c : INT := 1;\n  q : BOOL; n : INT;\nEND_VAR\nq := GT(a, b, c);\nn := ADD(a, b, c);",
      "verdict": "compiles"
    },
    {
      "id": "string-library",
      "category": "Library",
      "intent": "supported",
      "title": "The nine IEC string functions, 1-based",
      "st": "VAR s : STRING := 'hello world'; part : STRING; at : INT; END_VAR\npart := MID(s, 5, 1);\nat := FIND(s, 'world');\npart := REPLACE(s, 'X', 1, 1);",
      "verdict": "compiles"
    },
    {
      "id": "lint",
      "category": "Types the subset lacks",
      "intent": "unsupported",
      "title": "LINT (64-bit signed)",
      "st": "VAR n : LINT; END_VAR",
      "verdict": "refused",
      "message": "LINT is not supported: 64-bit integers need exact arithmetic a JS number cannot provide; use DINT, or LREAL if the range matters more than the last digits"
    },
    {
      "id": "ulint",
      "category": "Types the subset lacks",
      "intent": "unsupported",
      "title": "ULINT (64-bit unsigned)",
      "st": "VAR n : ULINT; END_VAR",
      "verdict": "refused",
      "message": "ULINT is not supported: 64-bit integers need exact arithmetic a JS number cannot provide; use UDINT, or LREAL if the range matters more than the last digits"
    },
    {
      "id": "lword",
      "category": "Types the subset lacks",
      "intent": "unsupported",
      "title": "LWORD (64-bit bit string)",
      "st": "VAR n : LWORD; END_VAR",
      "verdict": "refused",
      "message": "LWORD is not supported: 64-bit bit strings need exact arithmetic a JS number cannot provide; use DWORD, or two of them"
    },
    {
      "id": "wstring",
      "category": "Types the subset lacks",
      "intent": "unsupported",
      "title": "WSTRING",
      "st": "VAR s : WSTRING; END_VAR",
      "verdict": "refused",
      "message": "WSTRING is not supported: wide (UTF-16) strings are not implemented; STRING is"
    },
    {
      "id": "wstring-literal",
      "category": "Types the subset lacks",
      "intent": "unsupported",
      "title": "A double-quoted (WSTRING) literal",
      "st": "VAR s : STRING; END_VAR\ns := \"wide\";",
      "verdict": "refused",
      "message": "double-quoted literals are WSTRING, which is not implemented; use a single-quoted STRING"
    },
    {
      "id": "date",
      "category": "Types the subset lacks",
      "intent": "unsupported",
      "title": "DATE",
      "st": "VAR d : DATE; END_VAR",
      "verdict": "refused",
      "message": "DATE is not supported: calendar types are not implemented in this subset; TIME (a duration) is"
    },
    {
      "id": "tod",
      "category": "Types the subset lacks",
      "intent": "unsupported",
      "title": "TIME_OF_DAY",
      "st": "VAR t : TOD; END_VAR",
      "verdict": "refused",
      "message": "TOD is not supported: calendar types are not implemented in this subset; TIME (a duration) is"
    },
    {
      "id": "dt",
      "category": "Types the subset lacks",
      "intent": "unsupported",
      "title": "DATE_AND_TIME",
      "st": "VAR d : DT; END_VAR",
      "verdict": "refused",
      "message": "DT is not supported: calendar types are not implemented in this subset; TIME (a duration) is"
    },
    {
      "id": "var-external",
      "category": "Declarations",
      "intent": "supported",
      "title": "VAR_EXTERNAL, checked against the VAR_GLOBAL it names",
      "st": "VAR_GLOBAL Shared : INT; END_VAR\nVAR_EXTERNAL Shared : INT; END_VAR\nShared := 1;",
      "verdict": "compiles"
    },
    {
      "id": "located",
      "category": "Declarations",
      "intent": "supported",
      "title": "Located variables at inputs and outputs (AT %IX0.0, AT %QW4)",
      "st": "VAR\n  Eye AT %IX100.0 : BOOL;\n  Run AT %QX100.0 : BOOL;\n  Count AT %IW2 : INT;\nEND_VAR\nRun := NOT Eye;",
      "verdict": "compiles",
      "note": "The address is how the variable meets the world: a scene part whose PLC tag is %IX100.0 feeds it, and an external controller reaches it at that address. Declared in VAR (or VAR_GLOBAL), one variable per address, and the type has to fit the size (a bit holds BOOL, a word INT, UINT or WORD). A function block or function cannot locate a variable."
    },
    {
      "id": "located-memory",
      "category": "Declarations the subset lacks",
      "intent": "unsupported",
      "title": "Located variables in memory (AT %MW0)",
      "st": "VAR m AT %MW0 : INT; END_VAR",
      "verdict": "refused",
      "message": "%MW0 is a memory address, and only inputs (%I) and outputs (%Q) are bound to the scene here; locate 'm' at one of those, or drop the AT"
    },
    {
      "id": "program-pou",
      "category": "Tasks and programs",
      "intent": "supported",
      "title": "A PROGRAM ... END_PROGRAM, which with no CONFIGURATION runs on the scan time",
      "st": "PROGRAM Main\nVAR n : INT; END_VAR\nn := 1;\nEND_PROGRAM",
      "verdict": "compiles"
    },
    {
      "id": "configuration",
      "category": "Tasks and programs",
      "intent": "supported",
      "title": "CONFIGURATION / RESOURCE / TASK, each task on its own INTERVAL",
      "st": "PROGRAM Main\nVAR n : INT; END_VAR\nn := n + 1;\nEND_PROGRAM\n\nCONFIGURATION Cfg\n  RESOURCE Res ON PLC\n    TASK Fast(INTERVAL := T#10ms, PRIORITY := 1);\n    TASK Slow(INTERVAL := T#100ms, PRIORITY := 2);\n    PROGRAM A WITH Fast : Main;\n    PROGRAM B WITH Slow : Main;\n  END_RESOURCE\nEND_CONFIGURATION",
      "verdict": "compiles",
      "note": "Tasks run on SIM time: each comes due when its INTERVAL elapses, and tasks due together run by PRIORITY (0 first). A scan takes no sim time, so no task interrupts another. An instance variable is addressed as Instance.Name in the watch table and by scene tags."
    },
    {
      "id": "continuous-program",
      "category": "Tasks and programs",
      "intent": "supported",
      "title": "A PROGRAM instance with no task, which runs continuously",
      "st": "PROGRAM Main\nVAR n : INT; END_VAR\nn := n + 1;\nEND_PROGRAM\n\nCONFIGURATION Cfg\n  RESOURCE Res ON PLC\n    TASK Fast(INTERVAL := T#10ms, PRIORITY := 1);\n    PROGRAM A WITH Fast : Main;\n    PROGRAM Always : Main;\n  END_RESOURCE\nEND_CONFIGURATION",
      "verdict": "compiles",
      "note": "IEC gives a program with no task the lowest priority and re-runs it as soon as it finishes; on a sim clock that is once per physics step, after that step's periodic scans."
    },
    {
      "id": "configuration-body",
      "category": "Tasks and programs",
      "intent": "supported",
      "title": "Code outside every POU beside a CONFIGURATION, run by an instance that names no type",
      "st": "VAR n : INT; END_VAR\nn := n + 1;\n\nCONFIGURATION Cfg\n  RESOURCE Res ON PLC\n    TASK Fast(INTERVAL := T#10ms, PRIORITY := 1);\n    PROGRAM Main WITH Fast;\n  END_RESOURCE\nEND_CONFIGURATION",
      "verdict": "compiles",
      "note": "An extension: the variables are the controller's tags, seen by every POU under their bare names, and the statements run from the program instance with no type. Export writes that code as a PROGRAM of its own."
    },
    {
      "id": "event-task",
      "category": "Tasks and programs the subset lacks",
      "intent": "unsupported",
      "title": "An event task (SINGLE)",
      "st": "VAR_GLOBAL go : BOOL; END_VAR\nPROGRAM Main\nVAR n : INT; END_VAR\nn := 1;\nEND_PROGRAM\n\nCONFIGURATION Cfg\n  RESOURCE Res ON PLC\n    TASK OnEdge(SINGLE := go, PRIORITY := 1);\n    PROGRAM A WITH OnEdge : Main;\n  END_RESOURCE\nEND_CONFIGURATION",
      "verdict": "refused",
      "message": "an event task (SINGLE) is not implemented; a TASK here runs on its INTERVAL, and a PROGRAM instance with no task runs continuously"
    },
    {
      "id": "program-wiring",
      "category": "Tasks and programs the subset lacks",
      "intent": "unsupported",
      "title": "Wiring a PROGRAM instance's inputs and outputs in the CONFIGURATION",
      "st": "VAR_GLOBAL go : BOOL; END_VAR\nPROGRAM Main\nVAR_INPUT start : BOOL; END_VAR\nVAR n : INT; END_VAR\nn := 1;\nEND_PROGRAM\n\nCONFIGURATION Cfg\n  RESOURCE Res ON PLC\n    TASK Fast(INTERVAL := T#10ms, PRIORITY := 1);\n    PROGRAM A WITH Fast : Main (start := go);\n  END_RESOURCE\nEND_CONFIGURATION",
      "verdict": "refused",
      "message": "wiring a PROGRAM instance's inputs and outputs in the CONFIGURATION is not implemented; the scene binds them by name, as Instance.Variable"
    },
    {
      "id": "multiple-resources",
      "category": "Tasks and programs the subset lacks",
      "intent": "unsupported",
      "title": "More than one RESOURCE in a CONFIGURATION",
      "st": "PROGRAM Main\nVAR n : INT; END_VAR\nn := 1;\nEND_PROGRAM\n\nCONFIGURATION Cfg\n  RESOURCE A ON PLC\n    TASK T1(INTERVAL := T#10ms, PRIORITY := 1);\n    PROGRAM P1 WITH T1 : Main;\n  END_RESOURCE\n  RESOURCE B ON PLC\n    TASK T2(INTERVAL := T#10ms, PRIORITY := 1);\n    PROGRAM P2 WITH T2 : Main;\n  END_RESOURCE\nEND_CONFIGURATION",
      "verdict": "refused",
      "message": "2 RESOURCEs are declared; a controller here is one RESOURCE, so declare every TASK and PROGRAM instance inside one"
    },
    {
      "id": "type-alias",
      "category": "Declarations",
      "intent": "supported",
      "title": "A plain type alias (TYPE Count : INT)",
      "st": "TYPE Count : INT; END_TYPE\nVAR n : Count; END_VAR",
      "verdict": "compiles"
    },
    {
      "id": "named-array-type",
      "category": "Declarations",
      "intent": "supported",
      "title": "A named array type, one type under its name",
      "st": "TYPE Row : ARRAY [1..3] OF INT; END_TYPE\nVAR a : Row; b : Row := [1, 2, 3]; END_VAR\na := b;",
      "verdict": "compiles"
    },
    {
      "id": "constant-bounds",
      "category": "Declarations",
      "intent": "supported",
      "title": "Array bounds written as named CONSTANTs",
      "st": "VAR CONSTANT ZONES : INT := 4; END_VAR\nVAR eyes : ARRAY [0..ZONES - 1] OF BOOL; END_VAR\neyes[ZONES - 1] := TRUE;",
      "verdict": "compiles"
    },
    {
      "id": "struct-init",
      "category": "Declarations",
      "intent": "supported",
      "title": "Structured initial values: (field := value), and arrays of them",
      "st": "TYPE Pkg : STRUCT id : DINT; dest : INT; END_STRUCT; END_TYPE\nVAR p : Pkg := (id := 7, dest := 3); lane : ARRAY [1..2] OF Pkg := [(id := 1), (id := 2)]; END_VAR\np.dest := 4;",
      "verdict": "compiles"
    },
    {
      "id": "fb-call-expression",
      "category": "Statements the subset lacks",
      "intent": "unsupported",
      "title": "A function-block call used as an expression",
      "st": "VAR t : TON; q : BOOL; END_VAR\nq := t(IN := TRUE, PT := T#1s);",
      "verdict": "refused",
      "message": "unknown function 't'"
    },
    {
      "id": "time-library",
      "category": "Statements the subset lacks",
      "intent": "unsupported",
      "title": "The time-arithmetic library (ADD_TIME, ...)",
      "st": "VAR a : TIME := T#1s; b : TIME; END_VAR\nb := ADD_TIME(a, T#500ms);",
      "verdict": "refused",
      "message": "unknown function 'ADD_TIME'"
    },
    {
      "id": "string-case-extension",
      "category": "Statements the subset lacks",
      "intent": "unsupported",
      "title": "Vendor string extensions (TO_UPPER, TRIM, ...)",
      "st": "VAR s : STRING := 'abc'; END_VAR\ns := TO_UPPER(s);",
      "verdict": "refused",
      "message": "unknown function 'TO_UPPER'"
    },
    {
      "id": "sfc-step",
      "category": "Other IEC languages",
      "intent": "unsupported",
      "title": "Sequential Function Chart (STEP / TRANSITION / ACTION)",
      "st": "INITIAL_STEP Idle:\nEND_STEP\nTRANSITION FROM Idle TO Running := start;\nEND_TRANSITION",
      "verdict": "refused",
      "message": "INITIAL_STEP is not supported: Sequential Function Chart is a separate IEC language, not implemented here; express the state machine in ST (a CASE on a state variable is the usual translation)"
    },
    {
      "id": "class",
      "category": "3rd-edition OOP",
      "intent": "unsupported",
      "title": "CLASS",
      "st": "CLASS Tank\nVAR level : INT; END_VAR\nEND_CLASS",
      "verdict": "refused",
      "message": "CLASS is not supported: IEC 3rd-edition object orientation is not implemented; use a FUNCTION_BLOCK, which holds state and is what a PLC instantiates"
    },
    {
      "id": "interface",
      "category": "3rd-edition OOP",
      "intent": "unsupported",
      "title": "INTERFACE",
      "st": "INTERFACE IDrive\nEND_INTERFACE",
      "verdict": "refused",
      "message": "INTERFACE is not supported: IEC 3rd-edition object orientation is not implemented; there is no dynamic dispatch in this subset"
    },
    {
      "id": "method",
      "category": "3rd-edition OOP",
      "intent": "unsupported",
      "title": "METHOD",
      "st": "FUNCTION_BLOCK M\nMETHOD Start : BOOL\nEND_METHOD\nEND_FUNCTION_BLOCK",
      "verdict": "refused",
      "message": "METHOD is not supported: IEC 3rd-edition methods are not implemented; put the logic in the function block's own body, or in a FUNCTION it calls"
    },
    {
      "id": "property",
      "category": "3rd-edition OOP",
      "intent": "unsupported",
      "title": "PROPERTY",
      "st": "FUNCTION_BLOCK T\nPROPERTY Level : INT\nEND_PROPERTY\nEND_FUNCTION_BLOCK",
      "verdict": "refused",
      "message": "PROPERTY is not supported: IEC 3rd-edition properties are not implemented; expose a VAR_OUTPUT instead"
    },
    {
      "id": "extends",
      "category": "3rd-edition OOP",
      "intent": "unsupported",
      "title": "EXTENDS",
      "st": "FUNCTION_BLOCK B END_FUNCTION_BLOCK\nFUNCTION_BLOCK D EXTENDS B END_FUNCTION_BLOCK",
      "verdict": "refused",
      "message": "EXTENDS is not supported: IEC 3rd-edition inheritance is not implemented; compose instead: declare the base block as a VAR of the derived one"
    },
    {
      "id": "namespace",
      "category": "3rd-edition OOP",
      "intent": "unsupported",
      "title": "NAMESPACE",
      "st": "NAMESPACE Plant\nEND_NAMESPACE",
      "verdict": "refused",
      "message": "NAMESPACE is not supported: IEC 3rd-edition namespaces are not implemented; names are flat in this subset"
    },
    {
      "id": "pointer",
      "category": "3rd-edition OOP",
      "intent": "unsupported",
      "title": "Pointers (REF_TO, THIS^, SUPER^)",
      "st": "FUNCTION_BLOCK B\nVAR n : INT; END_VAR\nTHIS^.n := 1;\nEND_FUNCTION_BLOCK",
      "verdict": "refused",
      "message": "'^' dereferences a pointer, which is not implemented in this subset (nor are REF_TO, THIS^ and SUPER^)"
    },
    {
      "id": "bitstring-arithmetic",
      "category": "Refused for portability",
      "intent": "strict",
      "title": "Arithmetic on a bit string",
      "st": "VAR b : BYTE := 255; r : BYTE; END_VAR\nr := b + 1;",
      "verdict": "refused",
      "message": "+ is arithmetic; 'BYTE' is a bit string, which IEC does not do arithmetic on (convert it: BYTE_TO_USINT(...))",
      "note": "Arithmetic is ANY_NUM; BYTE/WORD/DWORD are ANY_BIT. Accepted here until the external oracle rejected it (2026-08-10): it built in the simulator and could not have built on any target."
    },
    {
      "id": "bitwise-on-number",
      "category": "Refused for portability",
      "intent": "strict",
      "title": "A bitwise operation on a number",
      "st": "VAR i : INT := 1; j : INT := 2; r : INT; END_VAR\nr := i AND j;",
      "verdict": "refused",
      "message": "AND is a bit-string operation; 'INT' is a number (convert it: INT_TO_WORD(...), or declare the variable BYTE/WORD/DWORD)",
      "note": "The mirror of the rule above. Write INT_TO_WORD(...) and operate on the bit string."
    },
    {
      "id": "not-on-number",
      "category": "Refused for portability",
      "intent": "strict",
      "title": "NOT on a number",
      "st": "VAR i : INT := 1; r : INT; END_VAR\nr := NOT i;",
      "verdict": "refused",
      "message": "NOT is a bit-string operation; 'INT' is a number (convert it: INT_TO_WORD(...), or declare the variable BYTE/WORD/DWORD)",
      "note": "Complement is defined on ANY_BIT. MATIEC refuses it too."
    },
    {
      "id": "shift-signed",
      "category": "Refused for portability",
      "intent": "strict",
      "title": "Shifting a signed integer",
      "st": "VAR i : INT := 8; r : INT; END_VAR\nr := SHR(i, 1);",
      "verdict": "refused",
      "message": "'SHR' shifts a bit string; 'INT' is a number (convert it: INT_TO_WORD(...))",
      "note": "SHL/SHR/ROL/ROR take a bit string, where the vacated bits are defined."
    },
    {
      "id": "missing-separator",
      "category": "Refused for portability",
      "intent": "strict",
      "title": "A missing statement separator",
      "st": "VAR n : INT; END_VAR\nIF n > 0 THEN n := 1; END_IF",
      "verdict": "refused",
      "message": "expected ';'"
    },
    {
      "id": "narrowing-store",
      "category": "Refused for portability",
      "intent": "strict",
      "title": "A narrowing store without the conversion",
      "st": "VAR r : REAL := 1.5; i : INT; END_VAR\ni := r;",
      "verdict": "refused",
      "message": "cannot assign REAL to 'i' of type INT (write REAL_TO_INT(...))",
      "note": "Only IEC’s safe widenings are implicit. The error names the conversion to write."
    },
    {
      "id": "bool-number-mix",
      "category": "Refused for portability",
      "intent": "strict",
      "title": "Mixing BOOL and a number",
      "st": "VAR b : BOOL; n : INT; END_VAR\nn := b;",
      "verdict": "refused",
      "message": "cannot assign BOOL to 'n' of type INT"
    },
    {
      "id": "for-control-write",
      "category": "Refused for portability",
      "intent": "strict",
      "title": "Assigning to a FOR control variable",
      "st": "VAR i : INT; END_VAR\nFOR i := 1 TO 10 DO i := 5; END_FOR;",
      "verdict": "refused",
      "message": "cannot assign to FOR control variable 'i' inside its own loop",
      "note": "IEC forbids it outright and MATIEC refuses it by name. Found by the external oracle (2026-08-10)."
    },
    {
      "id": "literal-out-of-range",
      "category": "Refused for portability",
      "intent": "strict",
      "title": "A typed literal that does not fit its type",
      "st": "VAR n : INT := INT#40000; END_VAR",
      "verdict": "refused",
      "message": "40000 does not fit in INT"
    },
    {
      "id": "case-duplicate",
      "category": "Refused for portability",
      "intent": "strict",
      "title": "Two names differing only in case",
      "st": "VAR Motor : BOOL; motor : BOOL; END_VAR",
      "verdict": "refused",
      "message": "duplicate declaration 'motor' ('Motor' is already declared; identifiers are case-insensitive)",
      "note": "Identifiers are case-insensitive, so these are one name declared twice."
    },
    {
      "id": "time-microseconds",
      "category": "Refused for portability",
      "intent": "strict",
      "title": "A duration in microseconds or nanoseconds (T#500us)",
      "st": "VAR t : TIME := T#500us; END_VAR",
      "verdict": "refused",
      "message": "T#500us: microseconds (us) and nanoseconds (ns) are LTIME units, which TIME here does not take: write T#0.5ms instead",
      "note": "us and ns are IEC 3rd-edition units, for LTIME; MATIEC, and so OpenPLC, read d, h, m, s and ms. TIME here takes fractions of a millisecond, so T#0.5ms says the same thing. It used to read as 0 ms, with no error."
    },
    {
      "id": "retain",
      "category": "Accepted here, with a caveat on the target",
      "intent": "permissive",
      "title": "RETAIN / NON_RETAIN",
      "st": "VAR RETAIN count : INT; END_VAR",
      "verdict": "compiles",
      "note": "The qualifier is consumed and does not change what the simulator does: RETAIN asks a variable to survive a warm restart, and there is no restart here: every run starts from the declared initial value, which is what a retentive variable is given on a cold start. A real target does have a restart, so the word still matters there."
    },
    {
      "id": "il-operator-name",
      "category": "Accepted here, with a caveat on the target",
      "intent": "permissive",
      "title": "A variable named after a standard function (ADD, GT, LT, ...)",
      "st": "VAR ADD : INT; lt : BOOL; END_VAR\nADD := 1;",
      "verdict": "compiles",
      "note": "IEC does not reserve these, so refusing them would invent a rule the standard lacks. But they are Instruction List OPERATORS, and a compiler sharing one lexer between the languages treats them as keywords in ST too. MATIEC refused VAR_OUTPUT lt : BOOL. Export warns about it."
    },
    {
      "id": "implicit-global-read",
      "category": "Accepted here, with a caveat on the target",
      "intent": "permissive",
      "title": "A POU touching a VAR_GLOBAL without declaring VAR_EXTERNAL",
      "st": "VAR_GLOBAL Shared : INT; END_VAR\nFUNCTION_BLOCK Reader\nVAR_OUTPUT v : INT; END_VAR\nv := Shared;\nEND_FUNCTION_BLOCK\n\nVAR r : Reader; n : INT; END_VAR\nr(v => n);",
      "verdict": "compiles",
      "note": "Semantically identical (the global resolves to the same image either way), but a block that touches a global without the declaration will not build on the target. Export warns about it."
    }
  ],
  "divergences": {
    "rule": "Each is exercised by a corpus case carrying a divergence note, and reported by the external oracle as known rather than failed.",
    "entries": [
      {
        "case": "same-width signed/unsigned ties resolve unsigned, both orders",
        "note": "IEC does not define mixing signed with unsigned at all; our rule is that the UNSIGNED type wins the same-width tie, in either operand order, so the result is deterministic. (Bit strings no longer take part: arithmetic on ANY_BIT is rejected outright, which is what removed the other half of this divergence.)"
      },
      {
        "case": "both operands of a BOOL AND are evaluated, so a guard does not protect a divide",
        "note": "MATIEC stops at a decided operand, so this program returns FALSE there instead of faulting. CODESYS 3.5 SP21 evaluates both operands and faults, stopping the PLC. A program whose behaviour depends on the difference is non-portable whichever way a simulator answers, so this engine answers the way that SURFACES it: a guard that silently worked here would take a CODESYS line down on download."
      },
      {
        "case": "FOR evaluates bounds once and owns its iteration",
        "note": "The loop bound is snapshotted at entry here, and BOTH independent IEC compilers whose behaviour could be inspected re-read it every iteration, so this program runs 3 times here and 10 times there. MATIEC: its generated C puts `if (I <= N)` inside the loop, reading the live variable. RuSTy (PLC-lang): its FOR lowering plants the user-written bound expression in the loop's exit test rather than a temporary, and says so in its own comment. Neither is provably right: IEC constrains FOR to a count \"determined in advance\", but no edition text reachable from here says WHEN the final-value expression is evaluated, and CODESYS, Beckhoff and Fernhill all document the comparison without saying how often the bound is computed. Two compilers agreeing is weaker evidence than it looks, since both lower FOR to a C/LLVM-style loop where re-reading is the lazy path. The snapshot is kept anyway: it is what makes the iteration count knowable at entry, which is the property FOR exists for (WHILE is the construct for a condition re-tested each pass), and the only runaway guard here is a count rather than a clock (the interpreter faults a loop after 1,000,000 iterations), so a body that grows its own bound would run a million iterations in one scan before that fault stopped it. Because it cannot be settled, it is not left silent either: exporting a loop whose body writes its own bound now WARNS (export.ts), which is the honest response to a difference nobody can adjudicate."
      },
      {
        "case": "R_TRIG / F_TRIG single-scan pulses",
        "note": "the literal IEC F_TRIG body fires when CLK starts false; we suppress that startup pulse, matching CODESYS/TwinCAT practice"
      },
      {
        "case": "SEL and MUX select; out-of-range MUX yields 0",
        "note": "IEC declares an out-of-range MUX selector an error; we return 0"
      },
      {
        "case": "REAL division by zero yields Infinity, not a fault",
        "note": "float error handling is vendor-defined; REAL division by zero stays IEEE +/-Infinity here, matching PLC float hardware, which flags but does not fault. Only INTEGER division faults the scan"
      },
      {
        "case": "an array index outside its bounds faults",
        "note": "Neither MATIEC nor CODESYS checks an array subscript. MATIEC emits C, which reads past the array and completes the scan, so the replay reports \"expected 0 scan lines, got 1\" for this case. CODESYS 3.5 SP21 Patch 5, a fresh default project, read index 9 of an ARRAY [0..3] as 16752 and ran on (whether one of its optional implicit-check POUs would change that was not measured). IEC requires a subscript to be within range but says nothing about what a runtime does when it is not, so faulting is a choice: this engine is where the mistake is cheapest to find."
      },
      {
        "case": "a store outside a subrange faults",
        "note": "Neither toolchain checks. CODESYS 3.5 SP21 Patch 5, a fresh default project, stored 250 into an INT (0..100) and ran on to the end (measured 2026-09-20; it ships optional implicit range-check POUs, which a default project does not have). MATIEC completes the scan too, which is why the replay reports \"expected 0 scan lines, got 1\" here. IEC declares the range but leaves the runtime free, so faulting is this engine being the place the mistake is cheapest to find, exactly as with an array bound."
      },
      {
        "case": "an out-of-range length or position clamps instead of erroring",
        "note": "IEC calls an out-of-range length/position an error. CODESYS and TwinCAT clamp to the string instead, and so do we — faulting a scan over a short string is not behaviour a controller survives."
      },
      {
        "case": "REAL_TO_STRING formatting is implementation-defined",
        "note": "IEC leaves float-to-text formatting to the implementation, and the vendors disagree (trailing zeros, exponent thresholds). We emit the shortest round-trip form. The VALUE is portable; the exact spelling is not, so do not parse it on the target."
      },
      {
        "case": "TIME_TO_STRING emits an IEC duration literal",
        "note": "The duration-literal spelling (T#1s500ms) is the CODESYS convention; IEC does not fix a TIME_TO_STRING format. Chosen so the output round-trips back through STRING_TO_TIME."
      }
    ]
  },
  "corpus": {
    "groups": 22,
    "cases": 184,
    "rejectionCases": 48,
    "replayedByOracle": 136,
    "withheldFromOracle": [
      {
        "case": "integer division by zero faults the scan",
        "why": "a scan fault has no comparable image: MATIEC-generated C divides by zero (SIGFPE / UB), it does not raise something the harness can read back"
      },
      {
        "case": "integer MOD by zero faults the scan",
        "why": "same as integer division by zero — the fault is not observable in the generated C"
      },
      {
        "case": "same-width signed/unsigned ties resolve unsigned, both orders",
        "why": "a strict IEC compiler rejects the mixed signed/unsigned operands outright, so there is no external answer to compare against"
      },
      {
        "case": "R_TRIG / F_TRIG single-scan pulses",
        "why": "the cold-start step is the documented F_TRIG divergence, so an external run is expected to disagree on scan 1 by construction"
      },
      {
        "case": "SEL and MUX select; out-of-range MUX yields 0",
        "why": "the out-of-range MUX step has no defined external answer — IEC calls it an error, so there is nothing to diff against"
      },
      {
        "case": "REAL division by zero yields Infinity, not a fault",
        "why": "vendor-defined behaviour with no portable spelling for the expected value"
      },
      {
        "case": "a VAR_GLOBAL is one image shared by every POU",
        "why": "strict IEC makes a POU declare VAR_EXTERNAL to reach a VAR_GLOBAL; here every POU sees globals directly, so MATIEC rejects the FB that writes Level without one. A documented permissiveness, not a semantic difference"
      },
      {
        "case": "an array of timers keeps one clock per element",
        "why": "no external compiler here can build it. MATIEC (2nd edition) has no arrays of function-block instances and refuses the declaration (\"invalid item data type in array specification\"). CODESYS and TwinCAT build it, but STruC++ 0.6.2 cannot parse a call on an array element (\"Expected `Assign`, found `(`\"), so it never reaches the check. The goldens are checked in-process, through both backends"
      },
      {
        "case": "an array of user function blocks, called by index with output bindings",
        "why": "no external compiler here can build it. MATIEC (2nd edition) has no arrays of function-block instances and refuses the declaration (\"invalid item data type in array specification\"). CODESYS and TwinCAT build it, but STruC++ 0.6.2 cannot parse a call on an array element (\"Expected `Assign`, found `(`\"), so it never reaches the check. The goldens are checked in-process, through both backends"
      },
      {
        "case": "a function block holds an array of timers of its own",
        "why": "no external compiler here can build it. MATIEC (2nd edition) has no arrays of function-block instances and refuses the declaration (\"invalid item data type in array specification\"). CODESYS and TwinCAT build it, but STruC++ 0.6.2 cannot parse a call on an array element (\"Expected `Assign`, found `(`\"), so it never reaches the check. The goldens are checked in-process, through both backends"
      },
      {
        "case": "a function-block instance in VAR_GLOBAL is one instance every POU shares",
        "why": "strict IEC makes a POU declare VAR_EXTERNAL to reach a VAR_GLOBAL; here every POU sees globals directly, so MATIEC rejects the block that reads gap without one. The same documented permissiveness as the VAR_GLOBAL case above, not a semantic difference"
      },
      {
        "case": "assigning one timer in an array to another is rejected",
        "why": "MATIEC refuses this program before reaching the rule it is about, because it has no arrays of function-block instances at all, so its rejection says nothing about the rule; STruC++ is not the strictness oracle (see scripts/oracle-strucpp.mjs). The rule is checked in-process, through both backends"
      },
      {
        "case": "writing a timer output through an array element is rejected",
        "why": "MATIEC refuses this program before reaching the rule it is about, because it has no arrays of function-block instances at all, so its rejection says nothing about the rule; STruC++ is not the strictness oracle (see scripts/oracle-strucpp.mjs). The rule is checked in-process, through both backends"
      },
      {
        "case": "a FUNCTION cannot hold function-block instances inside an array",
        "why": "MATIEC refuses this program before reaching the rule it is about, because it has no arrays of function-block instances at all, so its rejection says nothing about the rule; STruC++ is not the strictness oracle (see scripts/oracle-strucpp.mjs). The rule is checked in-process, through both backends"
      },
      {
        "case": "a named array type passes into a FUNCTION by value and is its result",
        "why": "MATIEC compiles this program, which is its verdict that the ST is valid, but the C it generates cannot pass an array into a function or take one back as the result (\"aggregate value used where an integer was expected\"), so the replay has nothing to run. STruC++ (3rd edition) builds it. Measured 2026-09-14; the goldens are checked in-process, through both backends"
      },
      {
        "case": "an array of structs starts from a list of field lists",
        "why": "no external compiler here can check it. MATIEC takes a (field := ...) list, nested ones included, but refuses the two kinds of list mixed: a [...] list inside a struct's initializer, or field lists inside an array's [...] list, whether the array type is named or spelled out (\"Initialization element identifier (...) is not declared in referenced structure/FB scope, or is set to value of incompatible datatype\"). STruC++ 0.6.2 has no structured initial values at all. The goldens are checked in-process, through both backends"
      },
      {
        "case": "an array field starts from a [...] list inside a struct's initializer",
        "why": "no external compiler here can check it. MATIEC takes a (field := ...) list, nested ones included, but refuses the two kinds of list mixed: a [...] list inside a struct's initializer, or field lists inside an array's [...] list, whether the array type is named or spelled out (\"Initialization element identifier (...) is not declared in referenced structure/FB scope, or is set to value of incompatible datatype\"). STruC++ 0.6.2 has no structured initial values at all. The goldens are checked in-process, through both backends"
      },
      {
        "case": "a store truncates to the declared capacity",
        "why": "NEITHER external compiler can take a sized STRING variable, in either spelling, so there is no toolchain to check the capacity against. MATIEC parses `VAR s : STRING[3];` and registers the variable (its grammar has IEC's own single_byte_string_var_declaration), but search_base_type.cc has no visitor for single_byte_string_spec_c, so the datatype never resolves and every USE reports \"Variable not declared in this scope\". Hoisting it to `TYPE Str3 : STRING[3]; END_TYPE` + `VAR s : Str3;` is worse, not better: simple_specification accepts only elementary and simple type names, while a string type name is registered under a different token class, so that form is a syntax error (\"invalid specification in variable declaration\") — measured in CI on 2026-08-11. STruC++ 0.6.2 rejects both spellings outright. The capacity semantics are covered by plc.test.ts instead."
      },
      {
        "case": "REAL_TO_STRING formatting is implementation-defined",
        "why": "the expected spelling is ours by definition — MATIEC prints its own float format, so a diff here would report a difference the standard permits rather than a defect"
      },
      {
        "case": "TIME_TO_STRING emits an IEC duration literal",
        "why": "same as REAL_TO_STRING — the format is implementation-defined, so a diff against MATIEC would be reporting a permitted difference"
      },
      {
        "case": "PRIORITY decides even when the PROGRAM lines are written the other way round",
        "why": "MATIEC ignores PRIORITY: for this file its generated RES0_run__ tests LOW and HIGH and then calls CONSUMER_body__(&READER) before PRODUCER_body__(&MAKER), in the order the PROGRAM lines are written (measured 2026-09-14; stage4/generate_c/generate_c.cc emits one gated call per line), so it has Reader see 0 and then 1. IEC gives the higher-priority task the processor first. The export warns when PROGRAM lines are out of priority order"
      },
      {
        "case": "rejects code outside every POU that no PROGRAM instance runs",
        "why": "code outside every POU beside a CONFIGURATION, and the PROGRAM line with no type that runs it, are this engine's extension, so an IEC compiler reads neither. An accepted program is exported as a PROGRAM of its own and compared that way; a refused one has no IEC form to hand a compiler"
      },
      {
        "case": "rejects two PROGRAM instances that both run the code outside every POU",
        "why": "code outside every POU beside a CONFIGURATION, and the PROGRAM line with no type that runs it, are this engine's extension, so an IEC compiler reads neither. An accepted program is exported as a PROGRAM of its own and compared that way; a refused one has no IEC form to hand a compiler"
      },
      {
        "case": "rejects a controller tag named like a VAR_GLOBAL",
        "why": "code outside every POU beside a CONFIGURATION, and the PROGRAM line with no type that runs it, are this engine's extension, so an IEC compiler reads neither. An accepted program is exported as a PROGRAM of its own and compared that way; a refused one has no IEC form to hand a compiler"
      },
      {
        "case": "rejects a PROGRAM instance on a task nobody declared",
        "why": "MATIEC's front end does not check the task an instance names: iec2c accepts this file and emits `if (MISSING) {` into Res0.c, which gcc then refuses (\"'MISSING' undeclared\"), measured 2026-09-14. So the program does not build on that toolchain either, but tier 1 stops at iec2c and would report the rule as over-strict. STruC++ refuses it outright"
      },
      {
        "case": "rejects a memory address, which the scene does not bind",
        "why": "a limit of this subset, not a rule of IEC: a memory address is valid, and MATIEC and STruC++ both build this program (measured 2026-09-17). It is refused here only because the scene binds inputs and outputs, so no external compiler can confirm the refusal"
      },
      {
        "case": "ladder: a seal-in circuit latches through its own contact",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: a coil passes its left link on, and a contact after it reads what it wrote",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: what flows past a coil is what its rung examined, not what the coil wrote",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: a branch runs its contact paths before its coil paths",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: branch paths that both hold contacts run top to bottom",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: P and N contacts sense a change of their variable once",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: P and N coils pulse on a change of their left link",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: set and reset coils latch, and the later rung wins",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: a timer box takes the rung on IN and carries Q on",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: a short timer on the rung turns on within the run",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: a counter with its reset on the rung and its output bound by name",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: a function box runs on EN, stores OUT, and passes the rung on",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: a block with EN on the rung runs only while the rung is on",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: a timer with EN on the rung measures the time the rung was off",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: a comparison starts a rung, and the contacts after it are in series",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: comparisons on a branch at the left rail",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: a comparison on a branch nested at the left rail",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: a step sequencer moves on in the rung that tests the step",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: a user function with a BOOL result starts a rung",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: rejects a comparison that does not stand at the left rail",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: rejects a contact on a number",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      },
      {
        "case": "ladder: rejects a coil on a number",
        "why": "LADDER ... END_LADDER is this engine's text for a ladder diagram; IEC gives LD no text, so an ST compiler never reads one. Exported, a rung is the ST it lowers to (the same text the engine ran), which the ST cases already hold to MATIEC; the ladder itself goes to CODESYS as a PLCopen <LD> body"
      }
    ],
    "editionRejects": [
      {
        "case": "INT + DINT computes at DINT width",
        "why": "IEC 2nd edition requires both operands of an arithmetic operator to have the same type, and MATIEC enforces that (\"Data type mismatch for '+' expression\"). 3rd edition defines implicit conversions, and CODESYS/TwinCAT/Siemens all widen INT into DINT here."
      },
      {
        "case": "implicit widening up a signed chain and into a real is accepted",
        "why": "IEC 2nd edition has no implicit conversions at all, so MATIEC refuses every one of these stores (\"Incompatible data types for ':=' operation\"). 3rd edition defines the widening set, and every vendor toolchain performs it."
      },
      {
        "case": "the BCD pair packs one decimal digit per nibble",
        "why": "IEC's normative conversion table defines BCD over UNSIGNED integers (BCD_TO_UINT and siblings) and MATIEC implements exactly that, so it refuses these names — to the point of being unable to compile the Annex F examples vendored in its own repo. IEC's Annex F itself uses the SIGNED spelling this engine follows (WEIGH: `INT_TO_BCD(BCD_TO_INT(gross_weight) - tare_weight)`), and STruC++ 0.6.2 compiles it. The rejection is the expected answer here, not a portability defect."
      },
      {
        "case": "function blocks inside a struct are called and read through their fields",
        "why": "MATIEC (2nd edition) has no function-block instances as struct fields and refuses the TYPE (\"invalid specification in structure element declaration\"). STruC++ (3rd edition) builds it, call and all, and so do CODESYS and TwinCAT."
      },
      {
        "case": "assigning an array copies every element",
        "why": "MATIEC gives every anonymous ARRAY declaration a type of its own, so two arrays declared identically still do not assign (\"Incompatible data types for ':=' operation\"). STruC++ (3rd edition) builds it. A named array type would satisfy MATIEC, but this subset has no TYPE ... : ARRAY declarations yet."
      },
      {
        "case": "a FUNCTION takes a whole array by value",
        "why": "the same rule as a whole-array assignment: MATIEC gives every anonymous ARRAY declaration a type of its own, so the argument does not match the input (\"Data type incompatibility for value passed in position 1 when invoking function 'Total'\"). STruC++ (3rd edition) builds it."
      },
      {
        "case": "an array bound can be a named CONSTANT or an expression over one",
        "why": "MATIEC calls a named CONSTANT in array bounds non-standard for IEC 61131-3 (\"Use of variables in array size limits is not allowed in IEC 61131-3 (use -a option to activate support for this non-standard feature)\"). STruC++ (3rd edition) builds it. Measured 2026-09-14."
      },
      {
        "case": "a CONSTANT can be defined from another CONSTANT",
        "why": "MATIEC takes only a literal as an initial value, so a CONSTANT defined from another is \"invalid initial value in specification with initialization\". STruC++ (3rd edition) builds it. Measured 2026-09-14."
      },
      {
        "case": "a located and an ordinary variable may share one VAR block",
        "why": "MATIEC takes located declarations only in a VAR block of their own, as IEC's grammar writes them, and refuses this one (\"invalid located variable declaration\", measured 2026-09-17). STruC++, the compiler OpenPLC ships, builds it, and so do CODESYS and TwinCAT; the export warns, so a program bound for a strict compiler can be split first"
      }
    ],
    "source": "src/plc/conformanceCases.ts",
    "groupTitles": [
      "integer arithmetic is faithful to declared width",
      "literal typing and mixed-width expressions",
      "conversions round and reinterpret per IEC",
      "bit-string operations work on the declared width",
      "operator precedence (IEC table: OR < XOR < AND < compare < add < mul)",
      "control flow",
      "timers against the IEC timing diagrams (dt = 0.1s scans)",
      "edge detectors, counters and latches",
      "selection and math builtins",
      "the operator functions (IEC call form: ADD, SUB, GT, ...)",
      "declarations, jumps and output bindings",
      "POUs — functions are stateless, function blocks retain state",
      "function-block instances live in arrays, structs and globals",
      "arrays and structs are values, copied whole",
      "IEC spellings: **, Type#Member, formal function calls",
      "named types: TYPE Name : ARRAY and plain aliases",
      "structured initial values: (field := value) and arrays of them",
      "file structure — POUs in any order, not just grouped by kind",
      "STRING is character data, ordered but not arithmetic",
      "tasks and programs: a CONFIGURATION runs PROGRAM instances on their own schedules",
      "located variables: AT %I / %Q binds a variable to an I/O point",
      "ladder diagram"
    ]
  },
  "oracleCoverage": {
    "what": "which accepted features an external IEC compiler has witnessed, at either of two grades: EXECUTED (compiled, stepped, and the variable image diffed against the golden scan for scan) or COMPILED ONLY (built by the 3rd-edition compiler but never run, because the 2nd-edition one refuses it by design)",
    "why": "the corpus SAMPLES the subset rather than covering it. \"unverified\" means unwitnessed by a real compiler, not known-broken — prefer the executed list where a program has the choice.",
    "method": {
      "functionsAndBlocks": "the engine's own lexer (valueRefs), so comments and string literals cannot count as coverage",
      "types": "whole-word match against case source with comments and string literals stripped — types are keywords, which the lexer does not emit as references",
      "excluded": "cases the oracle skips (never replayed) and `rejects` cases (they prove refusal, not behaviour)",
      "grades": "executed = replayed through MATIEC tier 2. compiledOnly = reached only by an `editionReject` case, which MATIEC is expected to refuse, so nothing ever ran it. Kept apart so the headline cannot drift upward on the weaker evidence as more editionReject cases accrue."
    },
    "casesConsidered": 99,
    "casesExecuted": 90,
    "compiledOnly": [
      "BCD_TO_INT",
      "INT_TO_BCD"
    ],
    "types": {
      "executed": [
        "BOOL",
        "BYTE",
        "DINT",
        "DWORD",
        "INT",
        "LREAL",
        "REAL",
        "SINT",
        "STRING",
        "TIME",
        "UDINT",
        "UINT",
        "USINT",
        "WORD"
      ],
      "compiledOnly": [],
      "unverified": []
    },
    "functionBlocks": {
      "executed": [
        "CTD",
        "CTU",
        "CTUD",
        "F_TRIG",
        "RS",
        "R_TRIG",
        "SR",
        "TOF",
        "TON",
        "TP"
      ],
      "compiledOnly": [],
      "unverified": []
    },
    "functions": {
      "executed": [
        "ABS",
        "ACOS",
        "ADD",
        "ASIN",
        "ATAN",
        "CONCAT",
        "COS",
        "DELETE",
        "DIV",
        "EQ",
        "EXP",
        "EXPT",
        "FIND",
        "GE",
        "GT",
        "INSERT",
        "LE",
        "LEFT",
        "LEN",
        "LIMIT",
        "LN",
        "LOG",
        "LT",
        "MAX",
        "MID",
        "MIN",
        "MOVE",
        "MUL",
        "MUX",
        "NE",
        "REPLACE",
        "RIGHT",
        "ROL",
        "ROR",
        "SEL",
        "SHL",
        "SHR",
        "SIN",
        "SQRT",
        "SUB",
        "TAN",
        "TRUNC"
      ],
      "compiledOnly": [
        "BCD_TO_INT",
        "INT_TO_BCD"
      ],
      "unverified": []
    },
    "summary": {
      "types": "14/14",
      "functionBlocks": "10/10",
      "functions": "42/44"
    }
  },
  "externalChecks": {
    "when": "every push and pull request touching src/plc/ or an oracle script",
    "workflow": ".github/workflows/st-oracle.yml",
    "oracles": [
      {
        "id": "matiec",
        "name": "MATIEC (iec2c)",
        "edition": "IEC 61131-3, 2nd edition",
        "proves": "Every corpus program is rendered through the PRODUCT’s exporter and compiled by a real IEC compiler, then the generated C is built, stepped with the corpus inputs, and diffed against the goldens scan for scan.",
        "script": "scripts/oracle-matiec.mjs",
        "run": "npm run st-oracle",
        "pinned": "7949c0bda1787de9c7cacaa4876ede49f85262dd"
      },
      {
        "id": "strucpp",
        "name": "STruC++",
        "edition": "IEC 61131-3, 3rd edition",
        "proves": "The edition boundary. Every case MATIEC refuses only for being a 2nd-edition compiler must BUILD here, which turns \"the standard allows this\" from a comment into a check.",
        "script": "scripts/oracle-strucpp.mjs",
        "run": "npm run st-oracle:ed3",
        "pinned": "v0.6.2"
      },
      {
        "id": "plcopen",
        "name": "PLCopen TC6 schema",
        "edition": "PLCopen XML interchange",
        "proves": "The other export target: every exported document is validated against PLCopen’s own published schema, not against assertions written by the same hand as the emitter.",
        "script": "scripts/oracle-plcopen.mjs",
        "run": "npm run st-oracle:xml"
      },
      {
        "id": "import",
        "name": "Real-world import corpus",
        "edition": "Beremiz projects + IEC’s own Annex F examples",
        "proves": "The other DIRECTION: files nobody here wrote go through both importers, and every POU and data type they declare must reach either the program or the notes. Nothing may vanish in silence.",
        "script": "scripts/oracle-import.mjs",
        "run": "npm run st-oracle:import"
      }
    ]
  },
  "portability": {
    "export": [
      {
        "id": "iec-text",
        "name": "IEC 61131-3 source (.st)",
        "takenBy": "MATIEC, STruC++, RuSTy, and any vendor ST editor as paste",
        "extension": ".st",
        "reportsWarnings": true
      },
      {
        "id": "plcopen-xml",
        "name": "PLCopen TC6 interchange XML",
        "takenBy": "CODESYS, TwinCAT and the OEM toolchains built on them",
        "extension": ".xml",
        "reportsWarnings": true
      }
    ],
    "import": [
      {
        "id": "iec-text",
        "name": "IEC 61131-3 source (.st)",
        "adoptsScanTime": true,
        "scanMsFromSample": 40,
        "reportsWhatItCannotHold": true,
        "notesFromSample": [
          "AT %MX0.0: a variable located in memory. The scene binds inputs (%I) and outputs (%Q) only, so drop the AT clause, or move it to an I/O address, before running the program."
        ]
      },
      {
        "id": "plcopen-xml",
        "name": "PLCopen TC6 project",
        "loadsPous": true,
        "reportsWhatItCannotHold": true
      }
    ],
    "rule": "Anything a target may not carry comes back as a warning or a note. A program that quietly means something other than what its author wrote is worse than one that refuses."
  },
  "seams": [
    {
      "run": "npm run catalog",
      "what": "what parts exist, their parameters, and the PLC signals each expects"
    },
    {
      "run": "npm run capabilities",
      "what": "this manifest: what the ST engine accepts and refuses"
    },
    {
      "run": "npm run check-st -- <path...>",
      "what": "an ST file compiles"
    },
    {
      "run": "npm run validate-project -- <path>",
      "what": "a project document loads"
    },
    {
      "run": "npm run check-wiring -- <path>",
      "what": "every scene tag connects to a program variable"
    },
    {
      "run": "npm run test-project -- <folder>",
      "what": "the control logic behaves (JSON tests, real exit codes)"
    }
  ]
}
