Skip to content

Commit 3835c72

Browse files
feat(semantic-gate): compare vars BY NAME, and tighten tolerances to 1.00
Counting has a ceiling, and it is lower than it looks: renaming or retyping a variable keeps the count identical, so the check passes at ANY tolerance -- 1.00 included. Measured on a real 27-POU project: renaming one variable left the count at 62 -> 62 and every count-based check stayed green. That is the "wrong identifier" class: a name that crosses a boundary and no longer matches on the other side. It does not raise an error anywhere -- it just silently returns nothing, which is indistinguishable from "not there yet". What changed: - count_st_vars() now returns _var_names, parsed from the declaration (`name [, name2] [AT %addr] : type [:= init] ;`). - count_xml_vars() now returns _var_names from the XML <variable name="...">. - validate_semantic() emits VARS_MISSING_BY_NAME listing the names declared in the ST that are absent from the XML. Identifiers are compared case-folded (IEC 61131-3 is case-insensitive) and reported as written. - Default tolerances 0.75/0.70 -> 1.00/1.00. That 25%/30% slack was never used: on the same real project ST and XML match 1:1 on every POU, so 1.00 costs zero red and removes room for a real loss to hide in. Deliberate limit, to avoid false positives: the by-name comparison only runs when the .st declares exactly ONE POU, because count_st_vars scans the whole FILE while count_xml_vars scans a single POU. With 2+ POUs the sets are not comparable, and a gate that cries wolf is a gate that gets skipped. Proven by mutation, both directions: - renaming a variable in a real .st: count 62 -> 62 (count-based check blind, as predicted), by-name check FAILS naming the variable; - disabling the by-name comparison in production code: exactly 1 of 19 tests fails, and it is the one that asserts the rename -- surgical, not coupled. Tests: 15 -> 19. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 588c5af commit 3835c72

2 files changed

Lines changed: 147 additions & 2 deletions

File tree

python/lib/plcopen_validation/semantic_gate.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,13 @@
2121

2222
NS = "{http://www.plcopen.org/xml/tc6_0200}"
2323

24-
DEFAULT_VARS_TOLERANCE = 0.75 # if XML < 75% of the ST vars → fail
25-
DEFAULT_INITS_TOLERANCE = 0.7 # if XML < 70% of the ST inits → fail
24+
# Tolerances were 0.75/0.70 — a 25%/30% slack that was never used: measured against a real
25+
# 27-POU project, ST and XML match 1:1 on every POU, so 1.00 costs zero red and removes room
26+
# for a real loss to hide in. Note the ceiling of what counting can do: renaming a variable
27+
# keeps the count identical and passes at ANY tolerance, 1.00 included — that is what the
28+
# by-name comparison below is for.
29+
DEFAULT_VARS_TOLERANCE = 1.0 # if the XML has FEWER vars than the ST → fail
30+
DEFAULT_INITS_TOLERANCE = 1.0 # idem for initial values
2631

2732

2833
def count_st_vars(st_path: Path) -> dict[str, Any]:
@@ -77,6 +82,7 @@ def count_st_vars(st_path: Path) -> dict[str, Any]:
7782
total_vars = 0
7883
init_vars = 0
7984
array_init_vars = 0
85+
var_names: list[str] = []
8086
for line in text_clean.splitlines():
8187
s = line.strip()
8288
if re.match(r"^VAR(_\w+)?\s*(CONSTANT|RETAIN|PERSISTENT)?\s*$", s, re.IGNORECASE):
@@ -91,9 +97,18 @@ def count_st_vars(st_path: Path) -> dict[str, Any]:
9197
init_vars += 1
9298
if "[" in s.split(":=")[1] or s.split(":=")[1].strip().startswith("("):
9399
array_init_vars += 1
100+
# Declared names, for comparison BY NAME (not by count).
101+
# IEC 61131-3: `name [, name2] [AT %addr] : type [:= init] ;`
102+
decl = s.split(":")[0]
103+
decl = re.sub(r"\s+AT\s+%[IQM][XWBD]?[\d.]+", "", decl, flags=re.IGNORECASE)
104+
for nome in decl.split(","):
105+
nome = nome.strip()
106+
if re.match(r"^[A-Za-z_]\w*$", nome):
107+
var_names.append(nome)
94108
counts["total_var_decls"] = total_vars
95109
counts["vars_with_init"] = init_vars
96110
counts["vars_with_array_init"] = array_init_vars
111+
counts["_var_names"] = var_names
97112

98113
return dict(counts)
99114

@@ -118,6 +133,7 @@ def count_xml_vars(xml_root: etree._Element, pou_name: str) -> dict[str, Any]:
118133
counts["total_inits"] = 0
119134
return dict(counts)
120135

136+
var_names: list[str] = []
121137
for kind in ["inputVars", "outputVars", "inOutVars", "localVars",
122138
"externalVars", "globalVars"]:
123139
blocks = interface.findall(f"{NS}{kind}")
@@ -128,10 +144,13 @@ def count_xml_vars(xml_root: etree._Element, pou_name: str) -> dict[str, Any]:
128144
vars_in_block = block.findall(f"{NS}variable")
129145
var_count += len(vars_in_block)
130146
for v in vars_in_block:
147+
if v.get("name"):
148+
var_names.append(v.get("name"))
131149
if v.find(f"{NS}initialValue") is not None:
132150
init_count += 1
133151
counts[f"{kind}_var_count"] = var_count
134152
counts[f"{kind}_init_count"] = init_count
153+
counts["_var_names"] = var_names
135154

136155
counts["total_vars"] = sum(v for k, v in counts.items() if k.endswith("_var_count"))
137156
counts["total_inits"] = sum(v for k, v in counts.items() if k.endswith("_init_count"))
@@ -207,6 +226,29 @@ def validate_semantic(
207226
st_total = st_counts.get("total_var_decls", 0)
208227
xml_total = xml_counts.get("total_vars", 0)
209228

229+
# Comparison BY NAME — catches what counting never will: a renamed or
230+
# retyped variable keeps the count identical and slips through every
231+
# tolerance level. Only runs when the .st declares exactly ONE POU,
232+
# because count_st_vars scans the whole FILE while count_xml_vars scans a
233+
# single POU; with 2+ POUs the sets are not comparable and we would emit
234+
# false positives — and a gate that cries wolf is a gate that gets skipped.
235+
if len(pou_names) == 1:
236+
st_names = st_counts.get("_var_names") or []
237+
xml_names = xml_counts.get("_var_names") or []
238+
# IEC 61131-3 identifiers are case-insensitive; compare folded, report as written.
239+
xml_fold = {n.lower() for n in xml_names}
240+
faltando = [n for n in st_names if n.lower() not in xml_fold]
241+
if faltando:
242+
amostra = ", ".join(faltando[:8])
243+
resto = f" (+{len(faltando) - 8})" if len(faltando) > 8 else ""
244+
errors.append(ValidationError(
245+
line=None,
246+
location=f"{st.name}::{pou_name}",
247+
code="VARS_MISSING_BY_NAME",
248+
message=(f"{len(faltando)} var(s) declared in the ST are absent from the "
249+
f"XML by name: {amostra}{resto}"),
250+
))
251+
210252
if st_total > 0 and xml_total < st_total * vars_tolerance:
211253
pct = (xml_total * 100 // st_total) if st_total else 0
212254
errors.append(ValidationError(

python/tests/test_semantic_gate.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,106 @@ def test_semantic_source_dir_inexistente(tmp_path: Path) -> None:
231231
report = validate_semantic(xml, tmp_path / "no_src")
232232
assert not report.valid
233233
assert report.errors[0].code == "DIR_NOT_FOUND"
234+
235+
236+
# ─────────────────────── comparison BY NAME (not by count) ────────────────
237+
#
238+
# Counting has a ceiling: a renamed or retyped variable keeps the count identical and
239+
# passes at ANY tolerance, 1.00 included. Measured on a real 27-POU project: renaming a
240+
# single variable inside one POU left the count at 62 → 62 and every count-based check
241+
# stayed green. These tests pin the by-name comparison that does catch it.
242+
243+
244+
def test_count_st_vars_coleta_os_nomes_declarados(tmp_path: Path) -> None:
245+
"""Names come from the declaration: with AT %, and several per line."""
246+
st = tmp_path / "PRG_N.st"
247+
st.write_text("""PROGRAM PRG_N
248+
VAR
249+
bStart AT %IX0.0 : BOOL;
250+
iA, iB, iC : INT;
251+
rValue : REAL := 3.14;
252+
END_VAR
253+
iA := 1;
254+
END_PROGRAM""", encoding="utf-8")
255+
nomes = count_st_vars(st)["_var_names"]
256+
assert nomes == ["bStart", "iA", "iB", "iC", "rValue"], nomes
257+
258+
259+
def test_semantic_detecta_rename_que_a_contagem_nao_ve(tmp_path: Path) -> None:
260+
"""The whole point: same count, different name. This is the 'wrong tag' class —
261+
an identifier that crosses a boundary and no longer matches on the other side."""
262+
src = _make_source_dir(tmp_path, {"PRG_X.st": """PROGRAM PRG_X
263+
VAR
264+
iA : INT;
265+
iRENOMEADA : INT;
266+
iC : INT;
267+
END_VAR
268+
iA := 1;
269+
END_PROGRAM"""})
270+
# XML still carries the OLD name — count matches (3 = 3), name does not
271+
vars_xml = "".join([
272+
f'<variable name="i{n}"><type><INT/></type></variable>' for n in ("A", "B", "C")
273+
])
274+
xml = _make_xml_with_pou(tmp_path, "PRG_X", vars_xml=vars_xml)
275+
276+
report = validate_semantic(xml, src)
277+
assert not report.valid, "a rename with matching count must NOT pass"
278+
erro = next(e for e in report.errors if e.code == "VARS_MISSING_BY_NAME")
279+
assert "iRENOMEADA" in erro.message, erro.message
280+
# and prove the count-based check was blind to it, even at the strictest tolerance
281+
assert not any(e.code == "VARS_MISSING" for e in report.errors)
282+
283+
284+
def test_semantic_by_name_ignora_caixa(tmp_path: Path) -> None:
285+
"""IEC 61131-3 identifiers are case-insensitive: iValue == IVALUE."""
286+
src = _make_source_dir(tmp_path, {"PRG_X.st": """PROGRAM PRG_X
287+
VAR
288+
iValue : INT;
289+
END_VAR
290+
iValue := 1;
291+
END_PROGRAM"""})
292+
xml = _make_xml_with_pou(
293+
tmp_path, "PRG_X", vars_xml='<variable name="IVALUE"><type><INT/></type></variable>')
294+
report = validate_semantic(xml, src)
295+
assert report.valid, [e.message for e in report.errors]
296+
297+
298+
def test_semantic_by_name_nao_dispara_com_dois_pous_no_arquivo(tmp_path: Path) -> None:
299+
"""count_st_vars scans the whole FILE, count_xml_vars scans ONE POU: with 2+ POUs the
300+
sets are not comparable, and a false positive here would teach people to skip the gate."""
301+
src = _make_source_dir(tmp_path, {"DOIS.st": """PROGRAM PRG_A
302+
VAR
303+
iDoA : INT;
304+
END_VAR
305+
iDoA := 1;
306+
END_PROGRAM
307+
308+
PROGRAM PRG_B
309+
VAR
310+
iDoB : INT;
311+
END_VAR
312+
iDoB := 1;
313+
END_PROGRAM"""})
314+
xml = tmp_path / "dois.xml"
315+
xml.write_text('''<?xml version="1.0" encoding="utf-8"?>
316+
<project xmlns="http://www.plcopen.org/xml/tc6_0200" xmlns:xhtml="http://www.w3.org/1999/xhtml">
317+
<fileHeader companyName="" productName="X" productVersion="X" creationDateTime="2026-01-01T00:00:00"/>
318+
<contentHeader name="t" modificationDateTime="2026-01-01T00:00:00">
319+
<coordinateInfo><fbd><scaling x="1" y="1"/></fbd><ld><scaling x="1" y="1"/></ld><sfc><scaling x="1" y="1"/></sfc></coordinateInfo>
320+
</contentHeader>
321+
<types><dataTypes/><pous>
322+
<pou name="PRG_A" pouType="program">
323+
<interface><localVars><variable name="iDoA"><type><INT/></type></variable></localVars></interface>
324+
<body><ST><xhtml:p><![CDATA[(* x *)]]></xhtml:p></ST></body>
325+
</pou>
326+
<pou name="PRG_B" pouType="program">
327+
<interface><localVars><variable name="iDoB"><type><INT/></type></variable></localVars></interface>
328+
<body><ST><xhtml:p><![CDATA[(* x *)]]></xhtml:p></ST></body>
329+
</pou>
330+
</pous></types>
331+
<instances><configurations/></instances>
332+
</project>''', encoding="utf-8")
333+
334+
report = validate_semantic(xml, src)
335+
assert not any(e.code == "VARS_MISSING_BY_NAME" for e in report.errors), \
336+
"must not compare names when the file declares more than one POU"

0 commit comments

Comments
 (0)