From 82ff149373b4f55cb8b83d94c2f171a086b5c592 Mon Sep 17 00:00:00 2001 From: Cowork Supervisor Date: Sun, 12 Jul 2026 13:55:22 -0400 Subject: [PATCH] feat: structural schema lint for server definitions (#54) Closes #54 Co-Authored-By: Claude Fable 5 --- bcc.py | 9 ++++-- bcc_core.py | 57 +++++++++++++++++++++++++++++++++ tests/test_core.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/bcc.py b/bcc.py index caebee8..d8c15e1 100644 --- a/bcc.py +++ b/bcc.py @@ -2309,8 +2309,13 @@ class MainWindow(QMainWindow): self.validation_lbl.setStyleSheet(f"color: {WARN};") self.save_btn.setEnabled(False) return False - self.validation_lbl.setText("✓ valid") - self.validation_lbl.setStyleSheet(f"color: {GOOD};") + lint_warnings = core.lint_servers(self.servers) + if lint_warnings: + self.validation_lbl.setText(f"⚠ {lint_warnings[0]}") + self.validation_lbl.setStyleSheet(f"color: {WARN};") + else: + self.validation_lbl.setText("✓ valid") + self.validation_lbl.setStyleSheet(f"color: {GOOD};") self.save_btn.setEnabled(self.dirty) return True diff --git a/bcc_core.py b/bcc_core.py index be511cf..63767a3 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -1213,6 +1213,63 @@ def validate_servers(servers: list[ServerEntry]) -> list[str]: return problems +def lint_server(name: str, data: dict) -> list[str]: + """Return non-blocking structural warnings for a single server definition. + + Unlike validate_servers, nothing here blocks Save -- these are advisory + notes about shapes that will round-trip through JSON fine but are + probably not what the user intended (args given as a plain string + instead of a list, an env value that isn't a string, an unrecognized + `type`, unknown top-level fields, etc.). + """ + nm = name.strip() or "(unnamed)" + warnings: list[str] = [] + + if "command" in data and not isinstance(data["command"], str): + warnings.append(f"'{nm}': 'command' should be a string") + + if "args" in data: + args = data["args"] + if not isinstance(args, list): + warnings.append(f"'{nm}': 'args' should be a list (one argument per item)") + elif any(not isinstance(a, str) for a in args): + warnings.append( + f"'{nm}': 'args' contains non-string values " + "(they will be saved as-is; Claude expects strings)" + ) + + for field in ("env", "headers"): + if field not in data: + continue + val = data[field] + if not isinstance(val, dict): + warnings.append(f"'{nm}': '{field}' should be an object of string key/value pairs") + elif any(not isinstance(v, str) for v in val.values()): + warnings.append( + f"'{nm}': '{field}' contains non-string values " + "(they will be saved as-is; Claude expects strings)" + ) + + if "type" in data: + t = data["type"] + if t not in ("http", "sse", "stdio"): + warnings.append(f"'{nm}': 'type' should be one of http, sse, stdio (found {t!r})") + + extra = sorted(k for k in data if k not in KNOWN_FIELDS) + if extra: + warnings.append(f"'{nm}': extra fields preserved as-is: {', '.join(extra)}") + + return warnings + + +def lint_servers(servers: list[ServerEntry]) -> list[str]: + """Concatenate lint_server warnings across every entry, in order.""" + out: list[str] = [] + for s in servers: + out.extend(lint_server(s.name, s.data)) + return out + + # --------------------------------------------------------------------------- # # Search / filter # --------------------------------------------------------------------------- # diff --git a/tests/test_core.py b/tests/test_core.py index 67f17fe..76d8402 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -181,6 +181,85 @@ def test_valid_set_passes_clean(): assert c.validate_servers([c.ServerEntry("good", {"command": "node"}, True)]) == [] +# --------------------------------------------------------------------------- # +# 4b. Lint: non-blocking structural warnings +# --------------------------------------------------------------------------- # +def test_lint_flags_non_string_command(): + warnings = c.lint_server("x", {"command": 5}) + assert any("'command' should be a string" in w for w in warnings) + + +def test_lint_flags_args_not_a_list(): + warnings = c.lint_server("x", {"command": "node", "args": "-y foo"}) + assert any("'args' should be a list" in w for w in warnings) + + +def test_lint_flags_args_with_non_string_items(): + warnings = c.lint_server("x", {"command": "node", "args": ["-y", 5]}) + assert any("'args' contains non-string values" in w for w in warnings) + + +def test_lint_flags_env_not_a_dict(): + warnings = c.lint_server("x", {"command": "node", "env": ["FOO=bar"]}) + assert any("'env' should be an object" in w for w in warnings) + + +def test_lint_flags_env_with_non_string_values(): + warnings = c.lint_server("x", {"command": "node", "env": {"FOO": 5}}) + assert any("'env' contains non-string values" in w for w in warnings) + + +def test_lint_flags_headers_not_a_dict(): + warnings = c.lint_server("x", {"url": "https://x", "headers": ["Authorization: x"]}) + assert any("'headers' should be an object" in w for w in warnings) + + +def test_lint_flags_headers_with_non_string_values(): + warnings = c.lint_server("x", {"url": "https://x", "headers": {"Authorization": 5}}) + assert any("'headers' contains non-string values" in w for w in warnings) + + +def test_lint_flags_bad_type_value(): + warnings = c.lint_server("x", {"url": "https://x", "type": "websocket"}) + assert any("'type'" in w and "websocket" in w for w in warnings) + + +def test_lint_flags_extra_unknown_fields(): + warnings = c.lint_server("x", {"command": "node", "cwd": "/tmp", "timeout": 30}) + matches = [w for w in warnings if "extra fields preserved as-is" in w] + assert len(matches) == 1 + assert "cwd" in matches[0] + assert "timeout" in matches[0] + + +def test_lint_clean_server_yields_no_warnings(): + assert c.lint_server("good", {"command": "node", "args": ["a.js"]}) == [] + assert ( + c.lint_server( + "remote", {"url": "https://x", "type": "http", "headers": {"Authorization": "x"}} + ) + == [] + ) + + +def test_lint_servers_aggregates_across_entries(): + entries = [ + c.ServerEntry("a", {"command": 5}, True), + c.ServerEntry("b", {"url": "https://x", "type": "bogus"}, True), + ] + warnings = c.lint_servers(entries) + assert any("'a'" in w and "'command' should be a string" in w for w in warnings) + assert any("'b'" in w and "'type'" in w for w in warnings) + + +def test_lint_warning_is_not_a_blocking_problem(): + # args given as a single string isn't caught by validate_servers (a + # command is still present), but it's a lint warning. + entry = c.ServerEntry("x", {"command": "node", "args": "-y foo"}, True) + assert c.validate_servers([entry]) == [] + assert c.lint_servers([entry]) != [] + + # --------------------------------------------------------------------------- # # 5. Secrets: detection + redaction # --------------------------------------------------------------------------- #