diff --git a/README.md b/README.md index d39e324..6e2c222 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - **🪝 Automation Hooks** - Pre and post-generation shell commands - **🎯 Dry Run Mode** - Preview changes before applying them - **✅ Validation & Schema** - Built-in YAML validation and IDE support +- **📈 Dependency Graphs** - Visualize nested structure relationships as text, JSON, or Mermaid diagrams - **🤖 MCP Integration** - Model Context Protocol support for AI-assisted development workflows ## 🤔 Why structkit? @@ -82,6 +83,9 @@ structkit list # Validate a configuration structkit validate my-config.yaml +# Visualize nested structure dependencies +structkit graph project/python --format mermaid + # Start MCP server for AI integration structkit mcp --server ``` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d8d0089..c17b9fa 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -9,7 +9,7 @@ The `struct` CLI allows you to generate project structures from YAML configurati **Basic Usage:** ```sh -structkit {info,validate,generate,list,generate-schema,mcp,completion,init} ... +structkit {info,validate,generate,list,search,graph,generate-schema,mcp,completion,init} ... ``` ## Global Options @@ -129,6 +129,34 @@ structkit list [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] [-s STRUCTURES_PATH] - `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to structure definitions. +### `graph` + +Visualize `folders[].struct` dependencies between structure definitions. + +**Usage:** + +```sh +structkit graph [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] [-s STRUCTURES_PATH] [--all] [--format {text,json,mermaid}] [structure_definition] +``` + +**Arguments:** + +- `structure_definition` (optional): Structure name or local YAML file to graph. Required unless `--all` is provided. +- `--all`: Graph all available structures. +- `--format {text,json,mermaid}`: Output format (default: `text`). +- `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to custom structure definitions. + +Examples: + +```sh +structkit graph project/python +structkit graph terraform/apps/generic --format mermaid +structkit graph --all --format json +structkit graph -s ~/custom-structures service/api +``` + +The graph command reports missing references and dependency cycles. Mermaid output starts with `graph TD` and can be pasted into compatible Markdown documentation. + ### `generate-schema` Generate JSON schema for available structures. diff --git a/docs/mcp-integration.md b/docs/mcp-integration.md index a47178c..e3fac28 100644 --- a/docs/mcp-integration.md +++ b/docs/mcp-integration.md @@ -69,7 +69,28 @@ Generate a project structure using specified definition and options. - `mappings` (optional): Variable mappings for template substitution - `structures_path` (optional): Custom path to structure definitions -### 4. validate_structure +### 4. graph_structure +Visualize dependency graphs from `folders[].struct` references. + +```json +{ + "name": "graph_structure", + "arguments": { + "structure_definition": "project/python", + "structures_path": "/path/to/custom/structures", + "include_all": false, + "output_format": "mermaid" + } +} +``` + +**Parameters:** +- `structure_definition` (optional): Name or local YAML path to graph. Required unless `include_all` is `true` +- `structures_path` (optional): Custom path to structure definitions +- `include_all` (optional): Include all available structures instead of a single root (default: `false`) +- `output_format` (optional): Output format - `text`, `json`, or `mermaid` (default: `text`) + +### 5. validate_structure Validate a structure configuration YAML file. ```json diff --git a/docs/usage.md b/docs/usage.md index 81eb32e..7a8df1d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -4,6 +4,7 @@ Run the script with the following command using one of the following subcommands - `generate`: Generate the project structure based on the YAML configuration. - `generate-schema`: Generate JSON schema for available structure templates. +- `graph`: Visualize nested structure dependencies. - `validate`: Validate the YAML configuration file. - `info`: Display information about the script and its dependencies. - `list`: List the available structs @@ -92,6 +93,48 @@ structkit generate \ - `--log-file`: Write logs to specified file - `--mappings-file`: Provide external mappings file (can be used multiple times) +## Graph Command + +The `graph` command follows `folders[].struct` references so you can inspect how structures compose one another. It accepts a built-in structure name, a local YAML file, or `--all` to include every available structure. + +### Text output + +```sh +structkit graph project/python +``` + +Example output: + +```text +project/python +└── github/workflows/run-struct +``` + +### JSON output + +```sh +structkit graph --all --format json +``` + +JSON output includes `roots`, `nodes`, `edges`, `missing`, and `cycles` fields so automation can consume dependency data directly. + +### Mermaid output + +```sh +structkit graph terraform/apps/generic --format mermaid +``` + +Paste the output into Markdown or documentation systems that support Mermaid: + +```mermaid +graph TD + n_app["app"] --> n_library["library"] + classDef missing fill:#ffe6e6,stroke:#cc0000,color:#660000 + classDef cycle fill:#fff4cc,stroke:#d19a00,color:#5c3b00 +``` + +Use `-s, --structures-path` to include custom structures in resolution and graph output. Missing references are reported in text/JSON output and drawn with a dashed `missing` edge in Mermaid. Cycles are reported explicitly and highlighted with the Mermaid `cycle` class. + ## Generate Schema Command The `generate-schema` command creates JSON schema definitions for available structure templates, making it easier for tools and IDEs to provide autocompletion and validation. diff --git a/structkit/commands/graph.py b/structkit/commands/graph.py new file mode 100644 index 0000000..833c069 --- /dev/null +++ b/structkit/commands/graph.py @@ -0,0 +1,247 @@ +from structkit.commands import Command +import json +import os +import yaml +from structkit.completers import structures_completer + + +class GraphCommand(Command): + def __init__(self, parser): + super().__init__(parser) + parser.description = "Visualize structure dependencies" + structure_arg = parser.add_argument( + 'structure_definition', + nargs='?', + type=str, + help='Structure name or local YAML file to graph' + ) + structure_arg.completer = structures_completer + parser.add_argument( + '--all', + action='store_true', + help='Graph all available structures' + ) + parser.add_argument( + '--format', + choices=['text', 'json', 'mermaid'], + default='text', + help='Output format (default: text)' + ) + parser.add_argument( + '-s', + '--structures-path', + type=str, + help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH)', + default=os.getenv('STRUCTKIT_STRUCTURES_PATH', None) + ) + parser.set_defaults(func=self.execute) + + def execute(self, args): + self.logger.info("Building structure dependency graph") + if not args.all and not args.structure_definition: + self.parser.error("provide a structure name, local YAML file, or --all") + + graph = self.build_graph(args.structure_definition, args.structures_path, args.all) + + if args.format == 'json': + print(self.format_json(graph)) + elif args.format == 'mermaid': + print(self.format_mermaid(graph)) + else: + print(self.format_text(graph)) + + def build_graph(self, structure_definition=None, structures_path=None, include_all=False): + roots = self._all_structure_names(structures_path) if include_all else [structure_definition] + graph = { + 'roots': roots, + 'nodes': [], + 'edges': [], + 'missing': [], + 'cycles': [], + } + nodes = set() + edges = set() + missing = set() + cycles = set() + + for root in roots: + self._visit(root, structures_path, [], nodes, edges, missing, cycles) + + graph['nodes'] = sorted(nodes) + graph['edges'] = [ + {'from': source, 'to': target} + for source, target in sorted(edges) + ] + graph['missing'] = [ + {'from': source, 'to': target} + for source, target in sorted(missing) + ] + graph['cycles'] = [list(cycle) for cycle in sorted(cycles)] + return graph + + def format_json(self, graph): + return json.dumps(graph, indent=2) + + def format_mermaid(self, graph): + lines = ['graph TD'] + rendered_edges = set() + + for node in graph['nodes']: + if not any(edge['from'] == node or edge['to'] == node for edge in graph['edges']): + lines.append(f" {self._mermaid_id(node)}[\"{self._escape_mermaid_label(node)}\"]") + + for edge in graph['edges']: + rendered_edges.add((edge['from'], edge['to'])) + lines.append( + f" {self._mermaid_id(edge['from'])}[\"{self._escape_mermaid_label(edge['from'])}\"] --> " + f"{self._mermaid_id(edge['to'])}[\"{self._escape_mermaid_label(edge['to'])}\"]" + ) + + for miss in graph['missing']: + if (miss['from'], miss['to']) not in rendered_edges: + lines.append( + f" {self._mermaid_id(miss['from'])}[\"{self._escape_mermaid_label(miss['from'])}\"] -. missing .-> " + f"{self._mermaid_id(miss['to'])}[\"{self._escape_mermaid_label(miss['to'])}\"]" + ) + lines.append(f" class {self._mermaid_id(miss['to'])} missing") + + if graph['cycles']: + cycle_nodes = sorted({node for cycle in graph['cycles'] for node in cycle}) + for node in cycle_nodes: + lines.append(f" class {self._mermaid_id(node)} cycle") + + lines.append(' classDef missing fill:#ffe6e6,stroke:#cc0000,color:#660000') + lines.append(' classDef cycle fill:#fff4cc,stroke:#d19a00,color:#5c3b00') + return '\n'.join(lines) + + def format_text(self, graph): + lines = [] + for root in graph['roots']: + self._append_tree(root, graph, lines, set(), '') + + if graph['missing']: + lines.append('') + lines.append('Missing references:') + for miss in graph['missing']: + lines.append(f" - {miss['from']} -> {miss['to']}") + + if graph['cycles']: + lines.append('') + lines.append('Cycles:') + for cycle in graph['cycles']: + lines.append(f" - {' -> '.join(cycle)}") + + return '\n'.join(lines) + + def _append_tree(self, node, graph, lines, stack, prefix='', child_prefix=''): + label = node + if node in stack: + lines.append(f"{prefix}{label} (cycle)") + return + lines.append(f"{prefix}{label}") + prefix = child_prefix + + children = sorted(edge['to'] for edge in graph['edges'] if edge['from'] == node) + missing_children = sorted(miss['to'] for miss in graph['missing'] if miss['from'] == node) + child_entries = [(child, False) for child in children] + [(child, True) for child in missing_children] + next_stack = set(stack) + next_stack.add(node) + + for index, (child, is_missing) in enumerate(child_entries): + is_last = index == len(child_entries) - 1 + branch = '└── ' if is_last else '├── ' + continuation = ' ' if is_last else '│ ' + if is_missing: + lines.append(f"{prefix}{branch}{child} (missing)") + else: + self._append_tree(child, graph, lines, next_stack, prefix + branch, prefix + continuation) + + def _visit(self, structure_definition, structures_path, stack, nodes, edges, missing, cycles): + if structure_definition in stack: + cycle = stack[stack.index(structure_definition):] + [structure_definition] + cycles.add(tuple(cycle)) + nodes.add(structure_definition) + return + + config = self._load_yaml_config(structure_definition, structures_path) + if config is None: + if stack: + missing.add((stack[-1], structure_definition)) + else: + missing.add((structure_definition, structure_definition)) + return + + nodes.add(structure_definition) + next_stack = stack + [structure_definition] + for dependency in self._extract_dependencies(config): + if self._resolve_structure_path(dependency, structures_path) is None: + missing.add((structure_definition, dependency)) + continue + edges.add((structure_definition, dependency)) + self._visit(dependency, structures_path, next_stack, nodes, edges, missing, cycles) + + def _extract_dependencies(self, config): + dependencies = [] + for item in config.get('folders', []) or []: + if not isinstance(item, dict): + continue + for _, content in item.items(): + if not isinstance(content, dict) or 'struct' not in content: + continue + struct_value = content['struct'] + if isinstance(struct_value, list): + dependencies.extend(str(struct) for struct in struct_value) + elif isinstance(struct_value, str): + dependencies.append(struct_value) + return dependencies + + def _load_yaml_config(self, structure_definition, structures_path): + path = self._resolve_structure_path(structure_definition, structures_path) + if not path or not os.path.exists(path): + self.logger.error(f"❗ File not found for structure: {structure_definition}") + return None + with open(path, 'r') as f: + return yaml.safe_load(f) or {} + + def _resolve_structure_path(self, structure_definition, structures_path): + if not structure_definition: + return None + + if structure_definition.startswith('file://'): + return structure_definition[7:] + + if structure_definition.endswith('.yaml') and os.path.exists(structure_definition): + return structure_definition + + for base_path in self._structure_paths(structures_path): + file_path = os.path.join(base_path, f"{structure_definition}.yaml") + if os.path.exists(file_path): + return file_path + + return None + + def _all_structure_names(self, structures_path): + structures = set() + for base_path in self._structure_paths(structures_path): + if not os.path.exists(base_path): + continue + for root, _, files in os.walk(base_path): + for file in files: + if file.endswith('.yaml'): + file_path = os.path.join(root, file) + structures.add(os.path.relpath(file_path, base_path)[:-5]) + return sorted(structures) + + def _structure_paths(self, structures_path): + this_file = os.path.dirname(os.path.realpath(__file__)) + contribs_path = os.path.join(this_file, '..', 'contribs') + if structures_path: + return [structures_path, contribs_path] + return [contribs_path] + + def _mermaid_id(self, node): + safe = ''.join(char if char.isalnum() else '_' for char in node) + return f"n_{safe}" + + def _escape_mermaid_label(self, label): + return label.replace('\\', '\\\\').replace('"', '\\"') diff --git a/structkit/commands/mcp.py b/structkit/commands/mcp.py index dc603c1..dcf0202 100644 --- a/structkit/commands/mcp.py +++ b/structkit/commands/mcp.py @@ -49,6 +49,7 @@ def execute(self, args): print(" - list_structures: List all available structure definitions") print(" - get_structure_info: Get detailed information about a structure") print(" - generate_structure: Generate structures with various options") + print(" - graph_structure: Visualize structure dependency graphs") print(" - validate_structure: Validate structure configuration files") print("\nExamples:") print(" structkit mcp --server --transport stdio --debug") diff --git a/structkit/main.py b/structkit/main.py index 73e499c..006147f 100644 --- a/structkit/main.py +++ b/structkit/main.py @@ -8,6 +8,7 @@ from structkit.commands.validate import ValidateCommand from structkit.commands.list import ListCommand from structkit.commands.search import SearchCommand +from structkit.commands.graph import GraphCommand from structkit.commands.generate_schema import GenerateSchemaCommand from structkit.commands.mcp import MCPCommand from structkit.logging_config import configure_logging @@ -36,6 +37,7 @@ def get_parser(): GenerateCommand(subparsers.add_parser('generate', help='Generate the project structure')) ListCommand(subparsers.add_parser('list', help='List available structures')) SearchCommand(subparsers.add_parser('search', help='Search available structures by keyword')) + GraphCommand(subparsers.add_parser('graph', help='Visualize structure dependencies')) GenerateSchemaCommand(subparsers.add_parser('generate-schema', help='Generate JSON schema for available structures')) MCPCommand(subparsers.add_parser('mcp', help='MCP (Model Context Protocol) support')) diff --git a/structkit/mcp_server.py b/structkit/mcp_server.py index e18eba4..5fec835 100644 --- a/structkit/mcp_server.py +++ b/structkit/mcp_server.py @@ -6,6 +6,7 @@ 2. Getting detailed information about structures 3. Generating structures with various options 4. Validating structure configurations +5. Visualizing structure dependency graphs """ import asyncio import logging @@ -17,6 +18,7 @@ from fastmcp import FastMCP from structkit.commands.generate import GenerateCommand +from structkit.commands.graph import GraphCommand from structkit.commands.validate import ValidateCommand from structkit import __version__ @@ -167,6 +169,30 @@ class Args: return f"Dry run completed for structure '{structure_definition}' at '{base_path}'" return f"Structure '{structure_definition}' generated successfully at '{base_path}'" + def _graph_structure_logic( + self, + structure_definition: Optional[str] = None, + structures_path: Optional[str] = None, + include_all: bool = False, + output_format: str = "text", + ) -> str: + if not include_all and not structure_definition: + return "Error: structure_definition is required unless include_all is true" + + if output_format not in {"text", "json", "mermaid"}: + return "Error: output_format must be one of: text, json, mermaid" + + import argparse + dummy_parser = argparse.ArgumentParser() + command = GraphCommand(dummy_parser) + graph = command.build_graph(structure_definition, structures_path, include_all) + + if output_format == "json": + return command.format_json(graph) + if output_format == "mermaid": + return command.format_mermaid(graph) + return command.format_text(graph) + def _validate_structure_logic(self, yaml_file: Optional[str]) -> str: if not yaml_file: return "Error: yaml_file is required" @@ -247,6 +273,32 @@ async def generate_structure( self.logger.debug(f"MCP response: generate_structure len={len(result)} preview=\n{preview}") return result + @self.app.tool(name="graph_structure", description="Visualize structure dependency graphs from folders[].struct references") + async def graph_structure( + structure_definition: Optional[str] = None, + structures_path: Optional[str] = None, + include_all: bool = False, + output_format: str = "text", + ) -> str: + self.logger.debug( + "MCP request: graph_structure args=%s", + { + "structure_definition": structure_definition, + "structures_path": structures_path, + "include_all": include_all, + "output_format": output_format, + }, + ) + result = self._graph_structure_logic( + structure_definition, + structures_path, + include_all, + output_format, + ) + preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]" + self.logger.debug(f"MCP response: graph_structure len={len(result)} preview=\n{preview}") + return result + @self.app.tool(name="validate_structure", description="Validate a structure configuration YAML file") async def validate_structure(yaml_file: str) -> str: self.logger.debug(f"MCP request: validate_structure args={{'yaml_file': {yaml_file!r}}}") diff --git a/tests/test_commands_more.py b/tests/test_commands_more.py index 8aee7b2..0ae28a7 100644 --- a/tests/test_commands_more.py +++ b/tests/test_commands_more.py @@ -167,6 +167,17 @@ async def fake_start(args): mock_start.assert_called_once() +def test_mcp_command_lists_graph_tool(parser): + command = MCPCommand(parser) + args = parser.parse_args([]) + + with patch('builtins.print') as mock_print: + command.execute(args) + + printed = "\n".join(call.args[0] for call in mock_print.call_args_list) + assert 'graph_structure' in printed + + # ValidateCommand error-path tests on helpers def test_validate_structure_config_errors(parser): diff --git a/tests/test_graph_command.py b/tests/test_graph_command.py new file mode 100644 index 0000000..265dd0b --- /dev/null +++ b/tests/test_graph_command.py @@ -0,0 +1,132 @@ +import argparse +import json + +import pytest + +from structkit.commands.graph import GraphCommand +from structkit.main import get_parser + + +def write_structure(base, name, folders=None): + path = base / f"{name}.yaml" + path.parent.mkdir(parents=True, exist_ok=True) + lines = ["files: []\n"] + if folders is not None: + lines.append("folders:\n") + for folder, struct_value in folders: + lines.append(f" - {folder}:\n") + if isinstance(struct_value, list): + lines.append(" struct:\n") + for item in struct_value: + lines.append(f" - {item}\n") + else: + lines.append(f" struct: {struct_value}\n") + path.write_text("".join(lines)) + return path + + +@pytest.fixture +def graph_command(): + return GraphCommand(argparse.ArgumentParser()) + + +def test_graph_command_registered(): + parser = get_parser() + args = parser.parse_args(['graph', 'project/python']) + assert callable(args.func) + assert args.structure_definition == 'project/python' + assert args.format == 'text' + + +def test_graph_single_dependency(graph_command, tmp_path): + write_structure(tmp_path, 'app', [('src', 'library')]) + write_structure(tmp_path, 'library') + + graph = graph_command.build_graph('app', str(tmp_path)) + + assert graph['nodes'] == ['app', 'library'] + assert graph['edges'] == [{'from': 'app', 'to': 'library'}] + assert graph['missing'] == [] + assert graph['cycles'] == [] + + +def test_graph_multiple_dependencies(graph_command, tmp_path): + write_structure(tmp_path, 'app', [('infra', ['terraform', 'kubernetes'])]) + write_structure(tmp_path, 'terraform') + write_structure(tmp_path, 'kubernetes') + + graph = graph_command.build_graph('app', str(tmp_path)) + + assert graph['edges'] == [ + {'from': 'app', 'to': 'kubernetes'}, + {'from': 'app', 'to': 'terraform'}, + ] + + +def test_graph_nested_dependencies(graph_command, tmp_path): + write_structure(tmp_path, 'app', [('src', 'api')]) + write_structure(tmp_path, 'api', [('config', 'shared/config')]) + write_structure(tmp_path, 'shared/config') + + graph = graph_command.build_graph('app', str(tmp_path)) + + assert {'from': 'app', 'to': 'api'} in graph['edges'] + assert {'from': 'api', 'to': 'shared/config'} in graph['edges'] + text = graph_command.format_text(graph) + assert 'app' in text + assert 'api' in text + assert 'shared/config' in text + + +def test_graph_missing_dependency(graph_command, tmp_path): + write_structure(tmp_path, 'app', [('src', 'missing-lib')]) + + graph = graph_command.build_graph('app', str(tmp_path)) + + assert graph['missing'] == [{'from': 'app', 'to': 'missing-lib'}] + assert 'missing-lib (missing)' in graph_command.format_text(graph) + + +def test_graph_cycles(graph_command, tmp_path): + write_structure(tmp_path, 'a', [('b-folder', 'b')]) + write_structure(tmp_path, 'b', [('a-folder', 'a')]) + + graph = graph_command.build_graph('a', str(tmp_path)) + + assert graph['cycles'] == [['a', 'b', 'a']] + assert 'a -> b -> a' in graph_command.format_text(graph) + + +def test_graph_json_output(graph_command, tmp_path): + write_structure(tmp_path, 'app', [('src', 'library')]) + write_structure(tmp_path, 'library') + + graph = graph_command.build_graph('app', str(tmp_path)) + payload = json.loads(graph_command.format_json(graph)) + + assert payload['roots'] == ['app'] + assert payload['edges'] == [{'from': 'app', 'to': 'library'}] + + +def test_graph_mermaid_output(graph_command, tmp_path): + write_structure(tmp_path, 'app', [('src', 'library')]) + write_structure(tmp_path, 'library') + + graph = graph_command.build_graph('app', str(tmp_path)) + mermaid = graph_command.format_mermaid(graph) + + assert mermaid.startswith('graph TD') + assert 'n_app["app"] --> n_library["library"]' in mermaid + assert 'classDef missing' in mermaid + + +def test_graph_all_includes_all_available_roots(graph_command, tmp_path): + write_structure(tmp_path, 'app', [('src', 'library')]) + write_structure(tmp_path, 'library') + write_structure(tmp_path, 'standalone') + + graph = graph_command.build_graph(structures_path=str(tmp_path), include_all=True) + + assert 'app' in graph['roots'] + assert 'library' in graph['roots'] + assert 'standalone' in graph['roots'] diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py index c3d0c7b..53bff82 100644 --- a/tests/test_mcp_integration.py +++ b/tests/test_mcp_integration.py @@ -1,6 +1,7 @@ """ Tests for MCP (Model Context Protocol) integration with FastMCP stdio transport. """ +import json import os import tempfile import unittest @@ -43,6 +44,41 @@ def test_generate_structure_logic(self): ) self.assertIsInstance(text, str) + def test_graph_structure_logic(self): + with tempfile.TemporaryDirectory() as temp_dir: + app_path = os.path.join(temp_dir, "app.yaml") + library_path = os.path.join(temp_dir, "library.yaml") + with open(app_path, "w") as f: + yaml.dump({ + "files": [], + "folders": [ + {"src": {"struct": "library"}} + ], + }, f) + with open(library_path, "w") as f: + yaml.dump({"files": []}, f) + + text = self.server._graph_structure_logic("app", temp_dir) + self.assertIn("app", text) + self.assertIn("library", text) + + payload = json.loads( + self.server._graph_structure_logic("app", temp_dir, output_format="json") + ) + self.assertEqual(payload["edges"], [{"from": "app", "to": "library"}]) + + mermaid = self.server._graph_structure_logic( + "app", temp_dir, output_format="mermaid" + ) + self.assertIn('n_app["app"] --> n_library["library"]', mermaid) + + def test_graph_structure_logic_validates_arguments(self): + text = self.server._graph_structure_logic() + self.assertIn("structure_definition is required", text) + + text = self.server._graph_structure_logic("app", output_format="dot") + self.assertIn("output_format must be one of", text) + def test_validate_structure_logic(self): # Missing yaml_file text = self.server._validate_structure_logic(None)