From a73e5c462d55b3958d5488fce59875d285d43c23 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 11:58:05 +1300 Subject: [PATCH] qet-mcp: qet_continuity no longer reports a folio that does not exist as clean qet.checkContinuity() answers a folio index it has no folio for with an empty list, so qet_continuity returned "0 findings" for it -- the same answer as a clean folio. The index counts from 0 while qet_elements numbers folios from 1, so passing the last folio's number checked nothing and said so cleanly; any other folio's number checked the next folio instead. An index with no folio is now refused before QElectroTech is launched, with the valid range and the counting rule. Each finding also carries folio_number (counted from 1) beside the existing folio index. The qet_continuity and qet_conductors descriptions and the README say how each tool counts. Nothing changes for a valid index. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 8 ++++++++ misc/qet-mcp/qet_mcp.py | 27 +++++++++++++++++++++++++-- misc/qet-mcp/test_qet_mcp.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 6d29fe6dc..6457106b7 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -264,6 +264,14 @@ Python, plus the hang guard on `addConductor` and the database refresh in ## Notes and limits +- **Two ways of numbering folios.** Tools that read the file — + `qet_project_info`, `qet_elements`, `qet_conductors`, `qet_diff` — number + folios from 1, as the application does. Tools that pass a folio to + QElectroTech's scripting API — `qet_edit` and `qet_continuity` — take an + index counted from 0, so the folio `qet_elements` calls 1 is `0` there. + `qet_continuity` refuses an index with no folio instead of reporting it + clean, and each of its findings carries both `folio` (the index) and + `folio_number` (counted from 1). - **The project database is reachable now, through `qet_query`.** It was not when this server was written, which is why every other structural tool here re-derives its answer from the XML. Prefer the views — diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 1c557067c..2372b28d0 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1582,6 +1582,17 @@ def tool_continuity(binary: str, project: str, folio: int | None = None, proj = Path(project).expanduser() if not proj.is_file(): raise ValueError(f"no such project: {proj}") + if folio is not None: + # qet.checkContinuity() answers an index it has no folio for with an + # empty list, which reads exactly like a clean folio. The index counts + # from 0 while qet_elements numbers folios from 1, so the likely + # mistake -- passing the last folio's number -- would pass silently. + count = len(list(_folios(_root(str(proj))))) + if not 0 <= folio < count: + raise ValueError( + f"folio {folio} does not exist: the project has {count} folio(s), " + f"indexed 0 to {count - 1} here. qet_continuity counts folios from 0; " + "the folio qet_elements calls N is N - 1.") folio_arg = -1 if folio is None else folio script = ("var out = qet.checkContinuity(%s);\n" @@ -1611,6 +1622,12 @@ def tool_continuity(binary: str, project: str, folio: int | None = None, result.setdefault("hint", "no findings came back at all -- this build's " "scripting API may predate qet.checkContinuity()") return result + for f in findings: + # "folio" is the 0-based index qet.checkContinuity() uses; add the + # number qet_elements and the application show, so the two can be + # matched without arithmetic. + if isinstance(f, dict) and isinstance(f.get("folio"), int): + f["folio_number"] = f["folio"] + 1 result["findings"] = findings result["finding_count"] = len(findings) result["errors"] = sum(1 for f in findings if f.get("severity") == "error") @@ -2162,7 +2179,8 @@ TOOLS = [ "type": "object", "properties": { "path": {"type": "string"}, - "folio": {"type": "integer"}, + "folio": {"type": "integer", + "description": "folio number counted from 1, as qet_elements and the application show it"}, "attribute": {"type": "string", "description": "an XML attribute of , e.g. cable"}, "non_empty": {"type": "boolean", "default": False}, @@ -2512,7 +2530,12 @@ TOOLS = [ "properties": { "binary": {"type": "string", "description": "path to the qelectrotech executable"}, "project": {"type": "string", "description": "the .qet to check; never modified"}, - "folio": {"type": "integer", "description": "check one folio only; omit for the whole project"}, + "folio": {"type": "integer", "description": + "check one folio only; omit for the whole project. An index " + "counted from 0, like qet_edit: the folio qet_elements calls 1 " + "is 0 here. An index with no folio is refused, not reported " + "clean. Each finding carries both \"folio\" (this index) and " + "\"folio_number\" (counted from 1)."}, "elements_dir": {"type": "string"}, "timeout": {"type": "integer", "default": 180}, }, diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index f0827225b..e446ed3bc 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -1259,6 +1259,25 @@ class QueryGuard(unittest.TestCase): m.tool_query("/x", "/no/such.qet", "SELECT 1") +class ContinuityFolioGuard(unittest.TestCase): + """qet.checkContinuity() answers a folio index it has no folio for with an + empty list, which looks like a clean folio; refuse it before launch.""" + + def test_an_index_with_no_folio_is_refused_before_launch(self): + with tempfile.TemporaryDirectory() as d: + qet = Path(d) / "p.qet" + qet.write_text("") + for folio in (2, 5, -1): + with self.subTest(folio=folio): + with self.assertRaisesRegex(ValueError, r"2 folio\(s\), indexed 0 to 1"): + m.tool_continuity("/nonexistent", str(qet), folio=folio) + + def test_the_descriptions_say_how_folios_are_counted(self): + props = {t["name"]: t["inputSchema"]["properties"] for t in m.TOOLS} + self.assertIn("counted from 0", props["qet_continuity"]["folio"]["description"]) + self.assertIn("counted from 1", props["qet_conductors"]["folio"]["description"]) + + class ProjectNewValidation(unittest.TestCase): def test_refuses_overwrite_and_bad_arguments(self): with tempfile.TemporaryDirectory() as tmp: @@ -2470,6 +2489,15 @@ class Integration(unittest.TestCase): self.assertGreater(both["finding_count"], 0) self.assertEqual(only_1["finding_count"], 0, "folio 1 has no elements at all") + def test_continuity_findings_carry_the_folio_number_qet_elements_uses(self): + base = self.sb.new(folios=2) + r = self.ok(self.sb.edit(base, [ + {"op": "add_element", "id": "a", "folio": 1, "path": COIL, "x": 100, "y": 100}])) + found = m.tool_continuity(BINARY, r["output"], folio=1, elements_dir=ELEMENTS)["findings"] + self.assertTrue(found) + self.assertEqual({(f["folio"], f["folio_number"]) for f in found}, {(1, 2)}) + self.assertEqual({e["folio"] for e in m.tool_elements(r["output"])["elements"]}, {2}) + def test_continuity_finds_a_report_link_colour_mismatch(self): """Reproduces qelectrotech/qelectrotech-source-mirror#974: a folio-jump conductor drawn in two different colours on either side