From bdaa0be6f95504365e57ceff98c89fd28e85abdd Mon Sep 17 00:00:00 2001 From: Andi Hellmund Date: Thu, 12 Mar 2026 23:12:43 +0100 Subject: [PATCH 1/3] changes to introduce channel --- README.md | 90 +++-- docs/LANGUAGE_SYNTAX.md | 283 ++++++-------- src/archml/compiler/parser.py | 74 ++-- src/archml/compiler/scanner.py | 16 +- src/archml/compiler/semantic_analysis.py | 251 ++++++++---- src/archml/model/__init__.py | 8 +- src/archml/model/entities.py | 26 +- src/archml/validation/checks.py | 195 ++++------ src/archml/views/topology.py | 165 +++----- tests/cli/test_main.py | 11 +- tests/compiler/test_artifact.py | 36 +- tests/compiler/test_compiler_integration.py | 38 +- tests/compiler/test_parser.py | 353 +++++++++-------- tests/compiler/test_scanner.py | 77 ++-- tests/compiler/test_semantic_analysis.py | 185 +++++---- .../undefined_connection_endpoint.archml | 20 +- tests/data/positive/compiler/simple.archml | 7 +- tests/data/positive/compiler/system.archml | 6 +- .../positive/imports/ecommerce_system.archml | 15 +- tests/data/positive/nested_components.archml | 8 +- .../positive/system_with_connections.archml | 39 +- tests/model/test_model.py | 69 ++-- tests/validation/test_checks.py | 367 +++++------------- tests/views/backend/test_diagram.py | 44 +-- tests/views/test_placement.py | 78 ++-- tests/views/test_topology.py | 358 +++++++---------- 26 files changed, 1234 insertions(+), 1585 deletions(-) diff --git a/README.md b/README.md index 77f1024..9705ad4 100644 --- a/README.md +++ b/README.md @@ -83,58 +83,58 @@ system ECommerce { title = "E-Commerce Platform" description = "Customer-facing online store." + // Channels decouple providers from requirers — no explicit wiring between pairs + channel order_in: OrderRequest + channel order_out: OrderConfirmation + channel payment: PaymentRequest { + protocol = "gRPC" + async = true + } + channel inventory: InventoryCheck { + protocol = "HTTP" + } + + user Customer { + provides OrderRequest via order_in + requires OrderConfirmation via order_out + } + component OrderService { title = "Order Service" description = "Accepts, validates, and processes customer orders." - // Internal pipeline: Validator feeds into Processor + // Internal channel wires Validator to Processor without naming either + channel validation: ValidationResult + component Validator { - requires OrderRequest as input - provides ValidationResult as output + requires OrderRequest + provides ValidationResult via validation } component Processor { - requires ValidationResult as input + requires ValidationResult via validation requires PaymentRequest requires InventoryCheck provides OrderConfirmation } - connect Validator.output -> Processor.input - - // Promote inner ports to the OrderService boundary - expose Validator.input // requires OrderRequest - expose Processor.PaymentRequest // requires PaymentRequest - expose Processor.InventoryCheck // requires InventoryCheck - expose Processor.OrderConfirmation // provides OrderConfirmation + // Unbound ports (OrderRequest, PaymentRequest, etc.) are visible at the boundary } component PaymentGateway { title = "Payment Gateway" tags = ["critical", "pci-scope"] - requires PaymentRequest + requires PaymentRequest via payment provides PaymentResult } component InventoryManager { title = "Inventory Manager" - requires InventoryCheck as requests [1..*] // accepts requests from multiple sources + requires InventoryCheck via inventory provides InventoryStatus } - - // Short form: interface is unambiguous on both sides - connect Customer -> OrderService by OrderRequest - connect OrderService -> Customer by OrderConfirmation - connect OrderService -> PaymentGateway by PaymentRequest - connect OrderService -> InventoryManager by InventoryCheck { - protocol = "HTTP" - } - connect PaymentGateway -> StripeAPI by PaymentRequest { - protocol = "HTTP" - async = true - } } ``` @@ -142,25 +142,23 @@ Large architectures split naturally across files. A `from ... import` statement ## Language at a Glance -| Keyword | Purpose | -| ---------------------------- | --------------------------------------------------------------------------------- | -| `system` | Group of components or sub-systems with a shared goal | -| `component` | Module with a clear responsibility; may nest sub-components | -| `user` | Human actor (role or persona) that interacts with the system | -| `interface` | Named contract of typed data fields; supports `@v1`, `@v2` versioning | -| `type` | Reusable data structure (used within interfaces) | -| `enum` | Constrained set of named values | -| `field` | Named, typed data element with optional `description` and `schema` | -| `requires` / `provides` | Declare consumed and exposed interface ports on a component or user | -| `requires X as name` | Named port — required when the same interface appears more than once | -| `requires X as name [1..*]` | Multi-port with cardinality — for fan-in patterns; `[N]`, `[*]`, `[M..N]` also valid | -| `expose Sub.port` | Promote a nested component's port to the enclosing component's boundary | -| `connect A -> B by I` | Short form: wire interface I between A and B (when I is unambiguous on both sides) | -| `connect A.p -> B.p` | Long form: wire named ports directly (no `by` needed) | -| `external` | Marks a system, component, or user as outside the development boundary | -| `from … import` | Bring specific definitions from another file into scope | -| `use component X` | Place an imported entity inside a system | -| `tags` | Arbitrary labels for filtering and view generation | +| Keyword | Purpose | +| ------------------------- | --------------------------------------------------------------------------------- | +| `system` | Group of components or sub-systems with a shared goal | +| `component` | Module with a clear responsibility; may nest sub-components | +| `user` | Human actor (role or persona) that interacts with the system | +| `interface` | Named contract of typed data fields; supports `@v1`, `@v2` versioning | +| `channel name: Interface` | Named conduit within a system or component scope; decouples providers from requirers | +| `type` | Reusable data structure (used within interfaces) | +| `enum` | Constrained set of named values | +| `field` | Named, typed data element with optional `description` and `schema` | +| `requires` / `provides` | Declare consumed and exposed interfaces on a component, system, or user | +| `requires X via channel` | Bind a `requires` declaration to a named channel | +| `provides X via channel` | Bind a `provides` declaration to a named channel | +| `external` | Marks a system, component, or user as outside the development boundary | +| `from … import` | Bring specific definitions from another file into scope | +| `use component X` | Place an imported entity inside a system | +| `tags` | Arbitrary labels for filtering and view generation | Primitive types: `String`, `Int`, `Float`, `Decimal`, `Bool`, `Bytes`, `Timestamp`, `Datetime` Container types: `List`, `Map`, `Optional` @@ -175,7 +173,7 @@ Delegates payment to PaymentGateway. """ ``` -Enum values and connection block attributes each occupy their own line — no commas needed. +Enum values and channel block attributes each occupy their own line — no commas needed. Full syntax reference: [docs/LANGUAGE_SYNTAX.md](docs/LANGUAGE_SYNTAX.md) @@ -266,7 +264,7 @@ archml sync-remote ## Project Status -ArchML is in early development. The functional architecture domain (systems, components, interfaces, connections) is implemented. Behavioral and deployment domains are planned. +ArchML is in early development. The functional architecture domain (systems, components, interfaces, channels) is implemented. Behavioral and deployment domains are planned. See [docs/PROJECT_SCOPE.md](docs/PROJECT_SCOPE.md) for the full vision and roadmap. diff --git a/docs/LANGUAGE_SYNTAX.md b/docs/LANGUAGE_SYNTAX.md index bd5d6e5..2b5498f 100644 --- a/docs/LANGUAGE_SYNTAX.md +++ b/docs/LANGUAGE_SYNTAX.md @@ -132,11 +132,11 @@ interface OrderRequest @v2 { When a component requires or provides a versioned interface, it references the version explicitly (e.g., `requires OrderRequest @v2`). Unversioned references default to the latest version. -`interface` defines a contract used in connections. `type` defines a building block used within interfaces. Both share the same field syntax — the distinction is semantic: interfaces appear on ports; types compose into fields. +`interface` defines a contract used in channels. `type` defines a building block used within interfaces. Both share the same field syntax — the distinction is semantic: interfaces appear on channels; types compose into fields. ### Component -A component is a module with a clear responsibility. Components declare the interfaces they **require** (consume from others) and **provide** (expose to others). `requires` declarations always come before `provides`. +A component is a module with a clear responsibility. Components declare the interfaces they **require** (consume) and **provide** (expose). `requires` declarations always come before `provides`. ``` component OrderService { @@ -149,52 +149,65 @@ component OrderService { } ``` -Components can nest sub-components to express internal structure: +Components can nest sub-components to express internal structure. Internal channels wire sub-components together without coupling them directly: ``` component OrderService { title = "Order Service" + channel validation: ValidationResult + component Validator { title = "Order Validator" - requires OrderRequest as input - provides ValidationResult as output + requires OrderRequest + provides ValidationResult via validation } component Processor { title = "Order Processor" - requires ValidationResult as input + requires ValidationResult via validation requires PaymentRequest requires InventoryCheck provides OrderConfirmation } - - connect Validator.output -> Processor.input - - expose Validator.input // OrderService requires OrderRequest - expose Processor.PaymentRequest // OrderService requires PaymentRequest - expose Processor.InventoryCheck // OrderService requires InventoryCheck - expose Processor.OrderConfirmation // OrderService provides OrderConfirmation } ``` +The `via` clause binds a `requires` or `provides` declaration to a named channel. Components that don't bind to a channel have unbound interface declarations, which are visible at the enclosing scope boundary. + ### System -A system groups components (or sub-systems) that work toward a shared goal. Systems may contain components and other systems, but components may not contain systems. +A system groups components (or sub-systems) that work toward a shared goal. Systems declare **channels** that wire their members together without naming specific pairs. Systems may contain components and other systems, but components may not contain systems. ``` system ECommerce { title = "E-Commerce Platform" description = "Customer-facing online store." - component OrderService { ... } - component PaymentGateway { ... } - component InventoryManager { ... } + channel payment: PaymentRequest { + protocol = "gRPC" + async = true + } + channel inventory: InventoryCheck { + protocol = "HTTP" + } + + component OrderService { + requires PaymentRequest via payment + requires InventoryCheck via inventory + provides OrderConfirmation + } + + component PaymentGateway { + provides PaymentRequest via payment + } - connect OrderService -> PaymentGateway by PaymentRequest - connect OrderService -> InventoryManager by InventoryCheck + component InventoryManager { + requires InventoryCheck via inventory + provides InventoryStatus + } } ``` @@ -204,10 +217,15 @@ Systems can nest other systems for large-scale decomposition: system Enterprise { title = "Enterprise Landscape" - system ECommerce { ... } - system Warehouse { ... } + channel inventory: InventorySync - connect ECommerce -> Warehouse by InventorySync + system ECommerce { + provides InventorySync via inventory + } + + system Warehouse { + requires InventorySync via inventory + } } ``` @@ -225,11 +243,23 @@ user Customer { } ``` -Users are leaf nodes — they cannot contain components or sub-users. A user participates in connections like any other entity: +Users are leaf nodes — they cannot contain components or sub-users. A user participates in channels like any other entity: ``` -connect Customer -> OrderService by OrderRequest -connect OrderService -> Customer by OrderConfirmation +system ECommerce { + channel order_in: OrderRequest + channel order_out: OrderConfirmation + + user Customer { + provides OrderRequest via order_in + requires OrderConfirmation via order_out + } + + component OrderService { + requires OrderRequest via order_in + provides OrderConfirmation via order_out + } +} ``` ### External Actors @@ -251,145 +281,62 @@ external user Admin { External entities appear in diagrams with distinct styling. They cannot be further decomposed (they are opaque). -## Ports +## Channels -Every `requires` and `provides` declaration on a component or user defines a **port** — a named connection point for a specific interface use. Ports are the targets that `connect` statements wire together. +A **channel** is a named conduit that carries a specific interface within a system or component scope. Channels decouple providers from requirers: each component binds to a channel by name without knowing who else is bound to it. -### Port names +### Channel declaration -By default a port's name is the interface name: +Channels are declared inside a system or component body: ``` -component OrderService { - requires PaymentRequest // port named "PaymentRequest" - provides OrderConfirmation // port named "OrderConfirmation" -} +channel : [@version] [{ attributes }] ``` -An explicit name is assigned with the `as` keyword: - ``` -component OrderService { - requires InventoryCheck as primary_inventory - requires InventoryCheck as backup_inventory - provides OrderConfirmation +channel payment: PaymentRequest +channel feed: DataFeed @v2 { + protocol = "gRPC" + async = true + description = "Asynchronous data feed channel." } ``` -A port name is **required** when a component declares two or more ports for the same interface in the same direction. When the interface appears only once in a given direction, naming is optional but encouraged whenever the port has a meaningful semantic role: +Channel attributes (each on its own line): -``` -component Filter { - requires DataStream as input - provides DataStream as output -} -``` +| Attribute | Type | Purpose | +| ------------- | ------- | ------------------------------------------ | +| `protocol` | string | Transport protocol (e.g. `"gRPC"`, `"HTTP"`) | +| `async` | boolean | Whether the channel is asynchronous | +| `description` | string | Human-readable explanation of the channel | -### Port multiplicity +### Binding to a channel -A port can accept more than one connection with a multiplicity annotation. Multiplicity always requires an explicit port name: - -| Annotation | Meaning | -| ---------- | --------------------------- | -| *(none)* | exactly one connection | -| `[N]` | exactly N connections | -| `[*]` | zero or more connections | -| `[1..*]` | one or more connections | -| `[M..N]` | between M and N connections | +A `requires` or `provides` declaration binds to a channel with the `via` keyword: ``` -component Aggregator { - requires Result as workers [1..*] // accepts results from one or more workers - provides Summary -} +requires [@version] via +provides [@version] via ``` -Multiple `connect` statements targeting the same multi-port are valid. The validator checks the actual connection count against the declared cardinality. - -### Boundary propagation with `expose` - -A nested component's port can be promoted to the enclosing component's boundary with the `expose` keyword. This makes the inner port reachable from outside without requiring callers to know about internal structure: - ``` component OrderService { - component Validator { - requires OrderRequest as input - provides ValidationResult as output - } - - component Processor { - requires ValidationResult as input - provides OrderConfirmation - } - - connect Validator.output -> Processor.input - - expose Validator.input // OrderService now requires OrderRequest - expose Processor.OrderConfirmation // OrderService now provides OrderConfirmation + requires PaymentRequest via payment // binds to the "payment" channel + requires InventoryCheck via inventory // binds to the "inventory" channel + provides OrderConfirmation // unbound — visible at the enclosing scope } ``` -The exposed port retains its original name on the enclosing boundary. An alias can be assigned with `as`: +The `via` clause is optional. An unbound interface declaration is still valid — it represents an interface the entity exposes at its boundary for the enclosing scope to wire. -``` -expose Validator.input as request -``` - -`expose` propagates one level at a time. To surface a port through multiple nesting levels, each intermediate component must declare its own `expose`. - -Direct `connect` from an outer scope into a nested component is not allowed. Use `expose` to make internal ports reachable at the enclosing boundary first. - -## Connections - -Connections are the data-flow edges of the architecture graph. A connection always links a **required** port on one side to a **provided** port on the other. The arrow `->` indicates the direction of the request (who initiates); data may flow in both directions as part of request/response. - -All connections are unidirectional. For bidirectional communication, use two separate connections. - -### Short form - -When each side has exactly one port for the given interface, the component names and the interface name are sufficient: - -``` -connect -> by -``` - -``` -connect Customer -> OrderService by OrderRequest -connect ServiceA -> ServiceB by RequestToB -connect ServiceB -> ServiceA by ResponseToA -``` - -### Long form - -When ports are named — because an interface appears more than once on a component, or for explicitness — reference port names directly. The interface is implied from the port and the `by` clause is omitted: - -``` -connect . -> . -``` - -``` -connect OrderService.primary_inventory -> PrimaryInventory.InventoryCheck -connect OrderService.backup_inventory -> BackupInventory.InventoryCheck -connect Validator.output -> Processor.input -``` - -Both forms are equivalent when the short form is unambiguous. The arrow direction (`->`) identifies which side is the providing port (left) and which is the requiring port (right), so a component that has both `requires X` and `provides X` for the same interface is still unambiguous. - -### Annotations - -Either form can carry a block of attributes. Each attribute must appear on its own line: - -``` -connect OrderService -> PaymentGateway by PaymentRequest { - protocol = "gRPC" - async = true - description = "Initiates payment processing for confirmed orders." -} -``` +The tooling validates that: +- The channel name in `via` is declared in the same scope (system or component body). +- The interface type of the binding matches the channel's declared interface type. +- Channel names are unique within their scope. ### Encapsulation -`connect` statements may only reference ports that are directly visible in the current scope — that is, ports belonging to direct children of the enclosing component or system. Connecting into a grandchild or deeper is not permitted; use `expose` to propagate the inner port to the enclosing boundary first. +A channel declared inside a component is local to that component — it is not visible from outside. Components without `via` bindings expose their unbound `requires`/`provides` declarations at the enclosing boundary. ## Tags @@ -433,12 +380,15 @@ component OrderService { } // file: systems/ecommerce.archml -from interfaces/order import OrderRequest, OrderConfirmation +from interfaces/order import OrderRequest, OrderConfirmation, PaymentRequest, InventoryCheck from components/order_service import OrderService system ECommerce { title = "E-Commerce Platform" + channel order_in: OrderRequest + channel order_out: OrderConfirmation + use component OrderService } ``` @@ -584,28 +534,23 @@ component OrderService { title = "Order Service" description = "Accepts, validates, and processes customer orders." + channel validation: ValidationResult + component Validator { title = "Order Validator" - requires OrderRequest as input - provides ValidationResult as output + requires OrderRequest + provides ValidationResult via validation } component Processor { title = "Order Processor" - requires ValidationResult as input + requires ValidationResult via validation requires PaymentRequest requires InventoryCheck provides OrderConfirmation } - - connect Validator.output -> Processor.input - - expose Validator.input // requires OrderRequest - expose Processor.PaymentRequest // requires PaymentRequest - expose Processor.InventoryCheck // requires InventoryCheck - expose Processor.OrderConfirmation // provides OrderConfirmation } // file: systems/ecommerce.archml @@ -629,34 +574,38 @@ external system StripeAPI { system ECommerce { title = "E-Commerce Platform" + channel order_in: OrderRequest + channel order_out: OrderConfirmation + channel payment: PaymentRequest { + protocol = "gRPC" + async = true + description = "Delegate payment processing." + } + channel inventory: InventoryCheck { + protocol = "HTTP" + } + + user Customer { + provides OrderRequest via order_in + requires OrderConfirmation via order_out + } + use component OrderService component PaymentGateway { title = "Payment Gateway" tags = ["critical", "pci-scope"] - requires PaymentRequest + requires PaymentRequest via payment provides PaymentResult } component InventoryManager { title = "Inventory Manager" - requires InventoryCheck + requires InventoryCheck via inventory provides InventoryStatus } - - connect Customer -> OrderService by OrderRequest - connect OrderService -> Customer by OrderConfirmation - connect OrderService -> PaymentGateway by PaymentRequest - connect OrderService -> InventoryManager by InventoryCheck { - protocol = "HTTP" - } - connect PaymentGateway -> StripeAPI by PaymentRequest { - protocol = "HTTP" - async = true - description = "Delegate payment processing to Stripe." - } } ``` @@ -668,17 +617,15 @@ system ECommerce { | `component` | Module with a clear responsibility; may nest sub-components. | | `user` | Human actor (role or persona) that interacts with the system; a leaf node. | | `interface` | Named contract of typed data fields. Supports versioning via `@v1`, `@v2`, etc. | +| `channel` | Named conduit that carries a specific interface within a system or component scope. | | `type` | Reusable data structure (used within interfaces). | | `enum` | Constrained set of named values. | | `field` | Named, typed data element. Supports `description` and `schema` annotations. | | `filetype` | Annotation on a `File` field specifying its format. | | `schema` | Free-text annotation describing expected content or format. | -| `requires` | Declares an interface port that an element consumes (listed before `provides`). | -| `provides` | Declares an interface port that an element exposes. | -| `as` | Assigns a name to a port (`requires X as name`) or provides an alias in `expose` (`expose Sub.port as name`). | -| `expose` | Promotes a nested component's port to the enclosing component's boundary (`expose Sub.port`). | -| `connect` | Wires a providing port to a requiring port. Short form: `connect A -> B by X`. Long form: `connect A.p -> B.p`. | -| `by` | Specifies the interface in the short form of `connect` (`connect A -> B by Interface`). | +| `requires` | Declares an interface an element consumes (listed before `provides`). | +| `provides` | Declares an interface an element exposes. | +| `via` | Binds a `requires` or `provides` declaration to a named channel (`requires X via channel`). | | `from` | Introduces the source path in an import statement (`from path import Name`). | | `import` | Names the specific entities to bring into scope; always paired with `from` (`from path import Name`). | | `use` | Places an imported entity into a system or component (e.g., `use component X`). | diff --git a/src/archml/compiler/parser.py b/src/archml/compiler/parser.py index 53755e3..3e18b30 100644 --- a/src/archml/compiler/parser.py +++ b/src/archml/compiler/parser.py @@ -9,9 +9,8 @@ from archml.compiler.scanner import Token, TokenType, tokenize from archml.model.entities import ( ArchFile, + ChannelDef, Component, - Connection, - ConnectionEndpoint, EnumDef, ImportDeclaration, InterfaceDef, @@ -79,6 +78,7 @@ def parse(source: str) -> ArchFile: TokenType.COMPONENT, TokenType.USER, TokenType.INTERFACE, + TokenType.CHANNEL, TokenType.TYPE, TokenType.ENUM, TokenType.FIELD, @@ -86,8 +86,7 @@ def parse(source: str) -> ArchFile: TokenType.SCHEMA, TokenType.REQUIRES, TokenType.PROVIDES, - TokenType.CONNECT, - TokenType.BY, + TokenType.VIA, TokenType.FROM, TokenType.IMPORT, TokenType.USE, @@ -174,7 +173,7 @@ def _expect_name_token(self) -> Token: """Consume the current token as a name. Accepts identifiers and keywords used in name positions (e.g. a field - named 'by'). Raises ParseError for structural tokens and EOF. + named 'via'). Raises ParseError for structural tokens and EOF. """ tok = self._current() if tok.type != TokenType.IDENTIFIER and tok.type not in _KEYWORD_TYPES: @@ -396,6 +395,8 @@ def _parse_component(self, is_external: bool) -> Component: comp.requires.append(self._parse_interface_ref(TokenType.REQUIRES)) elif self._check(TokenType.PROVIDES): comp.provides.append(self._parse_interface_ref(TokenType.PROVIDES)) + elif self._check(TokenType.CHANNEL): + comp.channels.append(self._parse_channel()) elif self._check(TokenType.COMPONENT): comp.components.append(self._parse_component(is_external=False)) elif self._check(TokenType.EXTERNAL): @@ -409,8 +410,6 @@ def _parse_component(self, is_external: bool) -> Component: inner.line, inner.column, ) - elif self._check(TokenType.CONNECT): - comp.connections.append(self._parse_connection()) else: tok = self._current() raise ParseError( @@ -442,6 +441,8 @@ def _parse_system(self, is_external: bool) -> System: system.requires.append(self._parse_interface_ref(TokenType.REQUIRES)) elif self._check(TokenType.PROVIDES): system.provides.append(self._parse_interface_ref(TokenType.PROVIDES)) + elif self._check(TokenType.CHANNEL): + system.channels.append(self._parse_channel()) elif self._check(TokenType.COMPONENT): system.components.append(self._parse_component(is_external=False)) elif self._check(TokenType.SYSTEM): @@ -466,8 +467,6 @@ def _parse_system(self, is_external: bool) -> System: ) elif self._check(TokenType.USE): self._parse_use_statement(system) - elif self._check(TokenType.CONNECT): - system.connections.append(self._parse_connection()) else: tok = self._current() raise ParseError( @@ -513,7 +512,7 @@ def _parse_user(self, is_external: bool) -> UserDef: """Parse: [external] user { [attrs] (requires|provides)* } Users are leaf nodes: they support title, description, tags, requires, - and provides, but no sub-entities or connections. + and provides, but no sub-entities or channels. """ self._expect(TokenType.USER) name_tok = self._expect(TokenType.IDENTIFIER) @@ -541,30 +540,28 @@ def _parse_user(self, is_external: bool) -> UserDef: return user # ------------------------------------------------------------------ - # Connection declarations + # Channel declarations # ------------------------------------------------------------------ - def _parse_connection(self) -> Connection: - """Parse: connect -> by [@version] [{ ... }] + def _parse_channel(self) -> ChannelDef: + """Parse: channel : [@version] [{ attrs }] - Each attribute inside the annotation block must appear on its own line - (line number strictly greater than the opening brace or the previous - attribute's last token). + Attributes (each on its own line): + protocol = "..." + async = true|false + description = "..." """ - self._expect(TokenType.CONNECT) - source_tok = self._expect(TokenType.IDENTIFIER) - self._expect(TokenType.ARROW) - target_tok = self._expect(TokenType.IDENTIFIER) - self._expect(TokenType.BY) + self._expect(TokenType.CHANNEL) + name_tok = self._expect(TokenType.IDENTIFIER) + self._expect(TokenType.COLON) iface_name_tok = self._expect(TokenType.IDENTIFIER) version: str | None = None if self._check(TokenType.AT): self._advance() ver_tok = self._expect(TokenType.IDENTIFIER) version = ver_tok.value - conn = Connection( - source=ConnectionEndpoint(entity=source_tok.value), - target=ConnectionEndpoint(entity=target_tok.value), + channel = ChannelDef( + name=name_tok.value, interface=InterfaceRef(name=iface_name_tok.value, version=version), ) if self._check(TokenType.LBRACE): @@ -574,38 +571,38 @@ def _parse_connection(self) -> Connection: attr_tok = self._current() if attr_tok.line <= last_attr_line: raise ParseError( - "Connection attributes must each be on a new line", + "Channel attributes must each be on a new line", attr_tok.line, attr_tok.column, ) - self._parse_connection_attr(conn) + self._parse_channel_attr(channel) last_attr_line = self._tokens[self._pos - 1].line self._expect(TokenType.RBRACE) - return conn + return channel - def _parse_connection_attr(self, conn: Connection) -> None: - """Parse a single attribute inside a connection annotation block.""" + def _parse_channel_attr(self, channel: ChannelDef) -> None: + """Parse a single attribute inside a channel annotation block.""" tok = self._current() if tok.type == TokenType.DESCRIPTION: - conn.description = self._parse_string_attr(TokenType.DESCRIPTION) + channel.description = self._parse_string_attr(TokenType.DESCRIPTION) elif tok.type == TokenType.IDENTIFIER: attr_name = self._advance().value self._expect(TokenType.EQUALS) if attr_name == "protocol": str_tok = self._expect(TokenType.STRING) - conn.protocol = str_tok.value + channel.protocol = str_tok.value elif attr_name == "async": bool_tok = self._expect(TokenType.TRUE, TokenType.FALSE) - conn.is_async = bool_tok.type == TokenType.TRUE + channel.is_async = bool_tok.type == TokenType.TRUE else: raise ParseError( - f"Unknown connection attribute {attr_name!r}", + f"Unknown channel attribute {attr_name!r}", tok.line, tok.column, ) else: raise ParseError( - f"Unexpected token {tok.value!r} in connection annotation block", + f"Unexpected token {tok.value!r} in channel annotation block", tok.line, tok.column, ) @@ -682,7 +679,7 @@ def _parse_type_ref(self) -> TypeRef: # ------------------------------------------------------------------ def _parse_interface_ref(self, keyword: TokenType) -> InterfaceRef: - """Parse: requires/provides [@version]""" + """Parse: requires/provides [@version] [via ]""" self._expect(keyword) name_tok = self._expect(TokenType.IDENTIFIER) version: str | None = None @@ -690,7 +687,12 @@ def _parse_interface_ref(self, keyword: TokenType) -> InterfaceRef: self._advance() ver_tok = self._expect(TokenType.IDENTIFIER) version = ver_tok.value - return InterfaceRef(name=name_tok.value, version=version) + via: str | None = None + if self._check(TokenType.VIA): + self._advance() # consume 'via' + via_tok = self._expect(TokenType.IDENTIFIER) + via = via_tok.value + return InterfaceRef(name=name_tok.value, version=version, via=via) # ------------------------------------------------------------------ # Common attribute parsers diff --git a/src/archml/compiler/scanner.py b/src/archml/compiler/scanner.py index dd40d85..ea609c7 100644 --- a/src/archml/compiler/scanner.py +++ b/src/archml/compiler/scanner.py @@ -22,6 +22,7 @@ class TokenType(enum.Enum): COMPONENT = "component" USER = "user" INTERFACE = "interface" + CHANNEL = "channel" TYPE = "type" ENUM = "enum" FIELD = "field" @@ -29,8 +30,7 @@ class TokenType(enum.Enum): SCHEMA = "schema" REQUIRES = "requires" PROVIDES = "provides" - CONNECT = "connect" - BY = "by" + VIA = "via" FROM = "from" IMPORT = "import" USE = "use" @@ -52,7 +52,6 @@ class TokenType(enum.Enum): COLON = ":" EQUALS = "=" AT = "@" - ARROW = "->" SLASH = "/" # Literals @@ -125,6 +124,7 @@ def tokenize(source: str) -> list[Token]: "component": TokenType.COMPONENT, "user": TokenType.USER, "interface": TokenType.INTERFACE, + "channel": TokenType.CHANNEL, "type": TokenType.TYPE, "enum": TokenType.ENUM, "field": TokenType.FIELD, @@ -132,8 +132,7 @@ def tokenize(source: str) -> list[Token]: "schema": TokenType.SCHEMA, "requires": TokenType.REQUIRES, "provides": TokenType.PROVIDES, - "connect": TokenType.CONNECT, - "by": TokenType.BY, + "via": TokenType.VIA, "from": TokenType.FROM, "import": TokenType.IMPORT, "use": TokenType.USE, @@ -256,13 +255,6 @@ def _scan_token(self) -> None: if ch in _SINGLE_CHAR_TOKENS: self._advance() self._tokens.append(Token(_SINGLE_CHAR_TOKENS[ch], ch, line, col)) - elif ch == "-": - if self._peek() == ">": - self._advance() # - - self._advance() # > - self._tokens.append(Token(TokenType.ARROW, "->", line, col)) - else: - raise LexerError("Unexpected character: '-'", line, col) elif ch == '"': self._scan_string(line, col) elif ch.isdigit(): diff --git a/src/archml/compiler/semantic_analysis.py b/src/archml/compiler/semantic_analysis.py index 8183713..f490cc2 100644 --- a/src/archml/compiler/semantic_analysis.py +++ b/src/archml/compiler/semantic_analysis.py @@ -13,7 +13,7 @@ from dataclasses import dataclass -from archml.model.entities import ArchFile, Component, Connection, EnumDef, InterfaceDef, InterfaceRef, System, UserDef +from archml.model.entities import ArchFile, ChannelDef, Component, EnumDef, InterfaceDef, InterfaceRef, System, UserDef from archml.model.types import FieldDef, ListTypeRef, MapTypeRef, NamedTypeRef, OptionalTypeRef, TypeRef # ############### @@ -50,12 +50,10 @@ def analyze( - Interface references in ``requires`` / ``provides`` must resolve to a known interface (locally defined or imported); locally-defined versioned references are checked against the actual declared version. - - Interface references in ``connect ... by`` statements follow the same - rules as requires/provides references. - - Connection endpoint names in a system must refer to direct members of - that system, to top-level entities in the file, or to imported names. - - Connection endpoint names in a component must refer to direct - sub-components of that component. + - Channel interface references follow the same rules as requires/provides. + - ``via`` bindings in requires/provides must reference a channel declared + in the same scope (system or component body). + - Duplicate channel names within a system or component scope. - Duplicate member names within nested components and systems. - Name conflicts between components and sub-systems within a system. - Import entity validation: when *resolved_imports* is provided, each @@ -107,13 +105,6 @@ def __init__( ) -> None: self._file = arch_file self._resolved = resolved_imports - # Top-level component, system, and user names visible at file scope. - # These are valid connection endpoints from within any nested system. - self._file_entity_names: set[str] = ( - {c.name for c in arch_file.components} - | {s.name for s in arch_file.systems} - | {u.name for u in arch_file.users} - ) def analyze(self) -> list[SemanticError]: """Run all semantic checks and return collected errors.""" @@ -192,6 +183,7 @@ def analyze(self) -> list[SemanticError]: all_interface_plain_names, local_interface_defs, imported_names, + channel_names=set(), ) ) @@ -207,6 +199,7 @@ def _check_component( all_interface_names: set[str], local_interface_defs: dict[tuple[str, str | None], InterfaceDef], imported_names: set[str], + parent_channel_names: set[str] | None = None, ) -> list[SemanticError]: errors: list[SemanticError] = [] ctx = f"component '{comp.name}'" @@ -219,19 +212,33 @@ def _check_component( ) ) - # Check requires / provides interface references. - for ref in comp.requires: + # Check for duplicate channel names. + errors.extend( + _check_duplicate_names( + [ch.name for ch in comp.channels], + "Duplicate channel name '{}' in " + ctx, + ) + ) + + own_channel_names = {ch.name for ch in comp.channels} + # The full channel scope available to this component's requires/provides: + # its own channels plus any channels from the enclosing scope. + available_channels = own_channel_names | (parent_channel_names or set()) + + # Check channel interface references. + for channel in comp.channels: errors.extend( - _check_interface_ref( + _check_channel( ctx, - ref, + channel, all_interface_names, local_interface_defs, imported_names, - "requires", ) ) - for ref in comp.provides: + + # Check requires / provides interface references and via bindings. + for ref in comp.requires: errors.extend( _check_interface_ref( ctx, @@ -239,25 +246,24 @@ def _check_component( all_interface_names, local_interface_defs, imported_names, - "provides", + "requires", + available_channels, ) ) - - # Check connections: endpoints must be direct sub-components. - sub_names = {c.name for c in comp.components} - for conn in comp.connections: + for ref in comp.provides: errors.extend( - _check_connection( + _check_interface_ref( ctx, - conn, - sub_names, + ref, all_interface_names, local_interface_defs, imported_names, + "provides", + available_channels, ) ) - # Recurse into sub-components. + # Recurse into sub-components, passing this component's own channels. for sub in comp.components: errors.extend( self._check_component( @@ -266,6 +272,7 @@ def _check_component( all_interface_names, local_interface_defs, imported_names, + parent_channel_names=own_channel_names, ) ) @@ -303,6 +310,13 @@ def _check_system( "Duplicate user name '{}' in " + ctx, ) ) + # Check for duplicate channel names. + errors.extend( + _check_duplicate_names( + [ch.name for ch in system.channels], + "Duplicate channel name '{}' in " + ctx, + ) + ) # Check for name conflicts between components, sub-systems, and users. comp_names = {c.name for c in system.components} @@ -313,19 +327,22 @@ def _check_system( for name in sorted((comp_names | sys_names) & user_names): errors.append(SemanticError(f"{ctx}: name '{name}' is used for both a user and a component or sub-system")) - # Check requires / provides interface references. - for ref in system.requires: + channel_names = {ch.name for ch in system.channels} + + # Check channel interface references. + for channel in system.channels: errors.extend( - _check_interface_ref( + _check_channel( ctx, - ref, + channel, all_interface_names, local_interface_defs, imported_names, - "requires", ) ) - for ref in system.provides: + + # Check requires / provides interface references and via bindings. + for ref in system.requires: errors.extend( _check_interface_ref( ctx, @@ -333,39 +350,34 @@ def _check_system( all_interface_names, local_interface_defs, imported_names, - "provides", + "requires", + channel_names, ) ) - - # Connection endpoints in a system may reference: - # 1. Direct members of this system (components, sub-systems, and users), - # 2. Top-level entities in the file (e.g. external systems defined - # at the top level and referenced in an internal connection), or - # 3. Imported names (brought in via `from ... import` and used via - # `use component/system/user`). - member_names = comp_names | sys_names | user_names - connection_scope = member_names | self._file_entity_names | imported_names - for conn in system.connections: + for ref in system.provides: errors.extend( - _check_connection( + _check_interface_ref( ctx, - conn, - connection_scope, + ref, all_interface_names, local_interface_defs, imported_names, + "provides", + channel_names, ) ) - # Recurse into children. + # Recurse into children, passing the system's channel names so that + # inline sub-components and users can bind to them. for comp in system.components: errors.extend( - self._check_component( + _check_component_in_system( comp, all_type_names, all_interface_names, local_interface_defs, imported_names, + channel_names, ) ) for sub_sys in system.systems: @@ -385,6 +397,7 @@ def _check_system( all_interface_names, local_interface_defs, imported_names, + channel_names, ) ) @@ -628,8 +641,10 @@ def _check_interface_ref( local_interface_defs: dict[tuple[str, str | None], InterfaceDef], imported_names: set[str], keyword: str, + channel_names: set[str], ) -> list[SemanticError]: - """Check that an interface reference resolves to a known interface. + """Check that an interface reference resolves to a known interface and that + any ``via`` binding refers to a channel declared in the current scope. When the referenced interface is locally defined (not imported), a versioned reference is additionally checked against the declared version. @@ -656,56 +671,148 @@ def _check_interface_ref( ) ) + # Check via binding when present. + if ref.via is not None and ref.via not in channel_names: + errors.append( + SemanticError( + f"{ctx}: '{keyword} {ref.name}{ver_str} via {ref.via}'" + f" — channel '{ref.via}' is not defined in this scope" + ) + ) + return errors +def _check_channel( + ctx: str, + channel: ChannelDef, + all_interface_names: set[str], + local_interface_defs: dict[tuple[str, str | None], InterfaceDef], + imported_names: set[str], +) -> list[SemanticError]: + """Check that a channel's interface reference resolves to a known interface.""" + return _check_interface_ref( + ctx, + channel.interface, + all_interface_names, + local_interface_defs, + imported_names, + f"channel '{channel.name}':", + channel_names=set(), # channels themselves don't bind via other channels + ) + + def _check_user( user: UserDef, all_interface_names: set[str], local_interface_defs: dict[tuple[str, str | None], InterfaceDef], imported_names: set[str], + channel_names: set[str], ) -> list[SemanticError]: """Check requires/provides interface references on a user entity.""" errors: list[SemanticError] = [] ctx = f"user '{user.name}'" for ref in user.requires: errors.extend( - _check_interface_ref(ctx, ref, all_interface_names, local_interface_defs, imported_names, "requires") + _check_interface_ref( + ctx, ref, all_interface_names, local_interface_defs, imported_names, "requires", channel_names + ) ) for ref in user.provides: errors.extend( - _check_interface_ref(ctx, ref, all_interface_names, local_interface_defs, imported_names, "provides") + _check_interface_ref( + ctx, ref, all_interface_names, local_interface_defs, imported_names, "provides", channel_names + ) ) return errors -def _check_connection( - ctx: str, - conn: Connection, - member_names: set[str], +def _check_component_in_system( + comp: Component, + all_type_names: set[str], all_interface_names: set[str], local_interface_defs: dict[tuple[str, str | None], InterfaceDef], imported_names: set[str], + parent_channel_names: set[str], ) -> list[SemanticError]: - """Check a single connection for endpoint and interface validity.""" + """Check a component that is declared inline within a system. + + The component's own channels are checked first, then requires/provides + are validated against both own channels and the parent system's channels. + """ errors: list[SemanticError] = [] + ctx = f"component '{comp.name}'" - src = conn.source.entity - tgt = conn.target.entity - if src not in member_names: - errors.append(SemanticError(f"{ctx}: connection source '{src}' is not a known member entity")) - if tgt not in member_names: - errors.append(SemanticError(f"{ctx}: connection target '{tgt}' is not a known member entity")) + # Check for duplicate sub-component names. + errors.extend( + _check_duplicate_names( + [c.name for c in comp.components], + "Duplicate sub-component name '{}' in " + ctx, + ) + ) + # Check for duplicate channel names. errors.extend( - _check_interface_ref( - ctx, - conn.interface, - all_interface_names, - local_interface_defs, - imported_names, - "connect ... by", + _check_duplicate_names( + [ch.name for ch in comp.channels], + "Duplicate channel name '{}' in " + ctx, ) ) + # The full channel scope available to this component's requires/provides + # is its own channels plus channels from the enclosing system. + own_channel_names = {ch.name for ch in comp.channels} + available_channels = own_channel_names | parent_channel_names + + # Check channel interface references. + for channel in comp.channels: + errors.extend( + _check_channel( + ctx, + channel, + all_interface_names, + local_interface_defs, + imported_names, + ) + ) + + # Check requires / provides with the combined channel scope. + for ref in comp.requires: + errors.extend( + _check_interface_ref( + ctx, + ref, + all_interface_names, + local_interface_defs, + imported_names, + "requires", + available_channels, + ) + ) + for ref in comp.provides: + errors.extend( + _check_interface_ref( + ctx, + ref, + all_interface_names, + local_interface_defs, + imported_names, + "provides", + available_channels, + ) + ) + + # Recurse into sub-components using only the component's own channels. + for sub in comp.components: + errors.extend( + _check_component_in_system( + sub, + all_type_names, + all_interface_names, + local_interface_defs, + imported_names, + own_channel_names, + ) + ) + return errors diff --git a/src/archml/model/__init__.py b/src/archml/model/__init__.py index 92623b3..1d0af9b 100644 --- a/src/archml/model/__init__.py +++ b/src/archml/model/__init__.py @@ -5,15 +5,15 @@ from archml.model.entities import ( ArchFile, + ChannelDef, Component, - Connection, - ConnectionEndpoint, EnumDef, ImportDeclaration, InterfaceDef, InterfaceRef, System, TypeDef, + UserDef, ) from archml.model.types import ( DirectoryTypeRef, @@ -45,10 +45,10 @@ "EnumDef", "TypeDef", "InterfaceDef", - "ConnectionEndpoint", - "Connection", + "ChannelDef", "Component", "System", + "UserDef", "ImportDeclaration", "ArchFile", ] diff --git a/src/archml/model/entities.py b/src/archml/model/entities.py index c495171..cbb1aa7 100644 --- a/src/archml/model/entities.py +++ b/src/archml/model/entities.py @@ -16,10 +16,11 @@ class InterfaceRef(BaseModel): - """A reference to an interface by name, optionally pinned to a version.""" + """A reference to an interface by name, optionally pinned to a version and bound to a channel.""" name: str version: str | None = None + via: str | None = None class EnumDef(BaseModel): @@ -54,17 +55,14 @@ class InterfaceDef(BaseModel): qualified_name: str = "" -class ConnectionEndpoint(BaseModel): - """One end of a connection: a named entity.""" +class ChannelDef(BaseModel): + """A named conduit that carries a specific interface within a system or component scope. - entity: str - - -class Connection(BaseModel): - """A directed data-flow edge linking a required interface to a provided one.""" + Channels decouple providers from requirers: components bind to a channel + by name rather than referencing each other directly. + """ - source: ConnectionEndpoint - target: ConnectionEndpoint + name: str interface: InterfaceRef protocol: str | None = None is_async: bool = False @@ -75,7 +73,7 @@ class UserDef(BaseModel): """A human actor (role or persona) that interacts with the system. Users are leaf nodes: they declare required and provided interfaces but - cannot contain sub-entities or connections. + cannot contain sub-entities or channels. """ name: str @@ -89,7 +87,7 @@ class UserDef(BaseModel): class Component(BaseModel): - """A module with declared interface ports and optional nested sub-components.""" + """A module with declared interface bindings and optional nested sub-components.""" name: str title: str | None = None @@ -97,8 +95,8 @@ class Component(BaseModel): tags: list[str] = _Field(default_factory=list) requires: list[InterfaceRef] = _Field(default_factory=list) provides: list[InterfaceRef] = _Field(default_factory=list) + channels: list[ChannelDef] = _Field(default_factory=list) components: list[Component] = _Field(default_factory=list) - connections: list[Connection] = _Field(default_factory=list) is_external: bool = False qualified_name: str = "" @@ -112,10 +110,10 @@ class System(BaseModel): tags: list[str] = _Field(default_factory=list) requires: list[InterfaceRef] = _Field(default_factory=list) provides: list[InterfaceRef] = _Field(default_factory=list) + channels: list[ChannelDef] = _Field(default_factory=list) components: list[Component] = _Field(default_factory=list) systems: list[System] = _Field(default_factory=list) users: list[UserDef] = _Field(default_factory=list) - connections: list[Connection] = _Field(default_factory=list) is_external: bool = False qualified_name: str = "" diff --git a/src/archml/validation/checks.py b/src/archml/validation/checks.py index 40447c1..187c6d0 100644 --- a/src/archml/validation/checks.py +++ b/src/archml/validation/checks.py @@ -11,7 +11,7 @@ from dataclasses import dataclass, field -from archml.model.entities import ArchFile, Component, Connection, InterfaceRef, System, UserDef +from archml.model.entities import ArchFile, Component, InterfaceRef, System, UserDef from archml.model.types import FieldDef, ListTypeRef, MapTypeRef, NamedTypeRef, OptionalTypeRef, TypeRef # ############### @@ -69,25 +69,19 @@ def validate(arch_file: ArchFile) -> ValidationResult: Checks performed: - 1. **Connection cycles** (error): Cycles in the directed connection graph - within any component or system scope are forbidden. A cycle such as - ``A -> B -> A`` indicates a circular data-flow dependency. - - 2. **Type definition cycles** (error): Recursive type or interface + 1. **Type definition cycles** (error): Recursive type or interface definitions where a type ultimately references itself through a chain of ``NamedTypeRef`` fields are forbidden. - 3. **Interface propagation** (error): If a system or component declares + 2. **Interface propagation** (error): If a system or component declares an interface in its ``requires`` or ``provides``, at least one direct member (sub-component or sub-system) must declare the same interface. This ensures that upstream declarations are grounded in the hierarchy. - 4. **Unconnected interfaces** (warning): For every member entity inside a - system or component, each ``requires`` interface must appear as the - target of a ``connect`` statement and each ``provides`` interface must - appear as the source of a ``connect`` statement within that same scope. - An interface that is declared but never connected indicates an - incomplete architecture. + 3. **Unused channels** (warning): For every channel declared inside a + component or system scope, at least one direct member must bind to it + via a ``via`` reference. A channel that nothing binds to indicates an + incomplete or dead architectural connection. Args: arch_file: The resolved ArchFile to validate. Qualified names should @@ -101,10 +95,9 @@ def validate(arch_file: ArchFile) -> ValidationResult: warnings: list[ValidationWarning] = [] errors: list[ValidationError] = [] - errors.extend(_check_connection_cycles(arch_file)) errors.extend(_check_type_cycles(arch_file)) errors.extend(_check_interface_propagation(arch_file)) - warnings.extend(_check_unconnected_interfaces(arch_file)) + warnings.extend(_check_unused_channels(arch_file)) return ValidationResult(warnings=warnings, errors=errors) @@ -119,6 +112,27 @@ def _entity_label(name: str, qualified_name: str) -> str: return qualified_name if qualified_name else name +def _collect_named_refs_from_type(type_ref: TypeRef) -> list[str]: + """Recursively collect all NamedTypeRef names reachable from a type reference.""" + if isinstance(type_ref, NamedTypeRef): + return [type_ref.name] + if isinstance(type_ref, ListTypeRef): + return _collect_named_refs_from_type(type_ref.element_type) + if isinstance(type_ref, MapTypeRef): + return _collect_named_refs_from_type(type_ref.key_type) + _collect_named_refs_from_type(type_ref.value_type) + if isinstance(type_ref, OptionalTypeRef): + return _collect_named_refs_from_type(type_ref.inner_type) + return [] + + +def _collect_named_refs_from_fields(fields: list[FieldDef]) -> list[str]: + """Collect all NamedTypeRef names from a list of field definitions.""" + result: list[str] = [] + for f in fields: + result.extend(_collect_named_refs_from_type(f.type)) + return result + + def _detect_cycle(graph: dict[str, list[str]]) -> list[str] | None: """Detect a cycle in a directed graph using DFS. @@ -163,64 +177,6 @@ def _dfs(node: str) -> list[str] | None: return None -def _check_connection_cycles(arch_file: ArchFile) -> list[ValidationError]: - """Return errors for cycles in the directed connection graph of any scope.""" - errors: list[ValidationError] = [] - - def _check_scope(connections: list[Connection], scope_label: str) -> None: - if not connections: - return - graph: dict[str, list[str]] = {} - for conn in connections: - graph.setdefault(conn.source.entity, []).append(conn.target.entity) - cycle = _detect_cycle(graph) - if cycle is not None: - cycle_str = " -> ".join(cycle) - errors.append(ValidationError(message=f"Connection cycle detected in '{scope_label}': {cycle_str}.")) - - def _process_component(component: Component) -> None: - label = _entity_label(component.name, component.qualified_name) - _check_scope(component.connections, label) - for sub in component.components: - _process_component(sub) - - def _process_system(system: System) -> None: - label = _entity_label(system.name, system.qualified_name) - _check_scope(system.connections, label) - for sub in system.systems: - _process_system(sub) - for comp in system.components: - _process_component(comp) - - for system in arch_file.systems: - _process_system(system) - for component in arch_file.components: - _process_component(component) - - return errors - - -def _collect_named_refs_from_type(type_ref: TypeRef) -> list[str]: - """Recursively collect all NamedTypeRef names reachable from a type reference.""" - if isinstance(type_ref, NamedTypeRef): - return [type_ref.name] - if isinstance(type_ref, ListTypeRef): - return _collect_named_refs_from_type(type_ref.element_type) - if isinstance(type_ref, MapTypeRef): - return _collect_named_refs_from_type(type_ref.key_type) + _collect_named_refs_from_type(type_ref.value_type) - if isinstance(type_ref, OptionalTypeRef): - return _collect_named_refs_from_type(type_ref.inner_type) - return [] - - -def _collect_named_refs_from_fields(fields: list[FieldDef]) -> list[str]: - """Collect all NamedTypeRef names from a list of field definitions.""" - result: list[str] = [] - for f in fields: - result.extend(_collect_named_refs_from_type(f.type)) - return result - - def _check_type_cycles(arch_file: ArchFile) -> list[ValidationError]: """Return errors for recursive cycles in type or interface definitions.""" errors: list[ValidationError] = [] @@ -319,78 +275,65 @@ def _check_system(system: System) -> None: return errors -def _iface_display(ref: InterfaceRef) -> str: - """Return a human-readable name for an interface reference, including version if set.""" - return f"{ref.name}@{ref.version}" if ref.version else ref.name +def _collect_via_names(members: list[Component | System | UserDef]) -> set[str]: + """Collect all ``via`` channel names referenced in any member's requires/provides.""" + result: set[str] = set() + for m in members: + for ref in m.requires: + if ref.via is not None: + result.add(ref.via) + for ref in m.provides: + if ref.via is not None: + result.add(ref.via) + return result -def _check_unconnected_interfaces(arch_file: ArchFile) -> list[ValidationWarning]: - """Return warnings for requires/provides not covered by any connect statement. +def _check_unused_channels(arch_file: ArchFile) -> list[ValidationWarning]: + """Return warnings for channels in a scope that no sub-entity binds to. - For every member of a component or system scope, each declared - ``requires`` interface must appear as a connection source and each - ``provides`` interface must appear as a connection target within that - scope. Leaf containers (those with no members) are not checked. + For every channel declared inside a component or system scope, at least + one direct member must reference the channel via a ``via`` clause on a + ``requires`` or ``provides`` declaration. A channel that nothing binds + to indicates an incomplete architectural connection. + + Scopes with no members (leaf entities) are not checked. """ warnings: list[ValidationWarning] = [] - def _check_scope( - members: list[Component | System | UserDef], - connections: list[Connection], - container_label: str, - ) -> None: - connected_as_source: set[tuple[str, tuple[str, str | None]]] = { - (conn.source.entity, _iface_key(conn.interface)) for conn in connections - } - connected_as_target: set[tuple[str, tuple[str, str | None]]] = { - (conn.target.entity, _iface_key(conn.interface)) for conn in connections - } - for member in members: - member_label = _entity_label(member.name, member.qualified_name) - for ref in member.requires: - if (member.name, _iface_key(ref)) not in connected_as_target: - warnings.append( - ValidationWarning( - message=( - f"'{member_label}' requires interface '{_iface_display(ref)}' " - f"but has no connect as target in '{container_label}'." - ) - ) - ) - for ref in member.provides: - if (member.name, _iface_key(ref)) not in connected_as_source: + def _check_component_channels(comp: Component) -> None: + if comp.channels and comp.components: + via_names = _collect_via_names(list(comp.components)) + label = _entity_label(comp.name, comp.qualified_name) + for ch in comp.channels: + if ch.name not in via_names: warnings.append( ValidationWarning( - message=( - f"'{member_label}' provides interface '{_iface_display(ref)}' " - f"but has no connect as source in '{container_label}'." - ) + message=f"Channel '{ch.name}' in '{label}' is not bound by any sub-component." ) ) + for sub in comp.components: + _check_component_channels(sub) - def _process_component(comp: Component) -> None: - if comp.components: - label = _entity_label(comp.name, comp.qualified_name) - sub_members: list[Component | System | UserDef] = list(comp.components) - _check_scope(sub_members, comp.connections, label) - for sub in comp.components: - _process_component(sub) - - def _process_system(system: System) -> None: + def _check_system_channels(system: System) -> None: all_members: list[Component | System | UserDef] = ( list(system.components) + list(system.systems) + list(system.users) ) - if all_members: + if system.channels and all_members: + via_names = _collect_via_names(all_members) label = _entity_label(system.name, system.qualified_name) - _check_scope(all_members, system.connections, label) + for ch in system.channels: + if ch.name not in via_names: + warnings.append( + ValidationWarning(message=f"Channel '{ch.name}' in '{label}' is not bound by any member.") + ) for sub in system.systems: - _process_system(sub) + _check_system_channels(sub) for comp in system.components: - _process_component(comp) + _check_component_channels(comp) for system in arch_file.systems: - _process_system(system) + _check_system_channels(system) for comp in arch_file.components: - _process_component(comp) + _check_component_channels(comp) return warnings diff --git a/src/archml/views/topology.py b/src/archml/views/topology.py index 38bfaa2..55384e7 100644 --- a/src/archml/views/topology.py +++ b/src/archml/views/topology.py @@ -223,8 +223,6 @@ class VizDiagram: def build_viz_diagram( entity: Component | System, - *, - external_entities: dict[str, Component | System | UserDef] | None = None, ) -> VizDiagram: """Build a :class:`VizDiagram` topology from a model entity. @@ -235,26 +233,17 @@ def build_viz_diagram( :class:`VizNode` instances in ``peripheral_nodes`` — one node per interface, positioned at the diagram boundary. - External actors that appear in ``connect`` statements but are not direct - children of *entity* are also appended to ``peripheral_nodes``. When - *external_entities* supplies model data for an actor, the resulting node - carries full metadata (title, description, tags, ports); otherwise a - minimal stub is created. - - If a ``connect`` statement references an interface that is not declared as - a port on either endpoint, an implicit port is created on that node so - that the edge can always be connected. + For each channel declared on the entity, components that bind to it via a + ``requires X via channel`` or ``provides X via channel`` declaration become + the endpoints of a :class:`VizEdge`. All providers and requirers of the + same channel are cross-connected pairwise. Args: entity: The focus component or system to visualize. - external_entities: Optional mapping from entity name to model entity - for resolving external connection endpoints. Only names that do - not match a direct child of *entity* are consulted. Returns: A :class:`VizDiagram` describing the full diagram topology. """ - ext = external_entities or {} entity_path = entity.qualified_name or entity.name root_id = _make_id(entity_path) @@ -286,66 +275,70 @@ def build_viz_diagram( children=list(child_node_map.values()), ) - # --- Peripheral nodes --- + # --- Peripheral nodes (terminal interface anchors at the diagram boundary) --- peripheral_nodes: list[VizNode] = [] - - # Terminals: the focus entity's own interface boundary points. for ref in entity.requires: peripheral_nodes.append(_make_terminal_node(ref, "requires")) for ref in entity.provides: peripheral_nodes.append(_make_terminal_node(ref, "provides")) - # External endpoints: actors referenced in connections but not children. - all_child_names = set(child_node_map) - external_node_map: dict[str, VizNode] = {} - for conn in entity.connections: - for ep_name in (conn.source.entity, conn.target.entity): - if ep_name in all_child_names or ep_name in external_node_map: - continue - ext_model = ext.get(ep_name) - ext_node = _make_external_node(ep_name, ext_model) - external_node_map[ep_name] = ext_node - peripheral_nodes.append(ext_node) - - # --- Edges (from explicit connect statements) --- - all_node_map: dict[str, VizNode] = {**child_node_map, **external_node_map} - edges: list[VizEdge] = [] + # --- Sub-entity map for channel binding lookup --- + all_sub_entity_map: dict[str, Component | System | UserDef] = {} + for comp in entity.components: + all_sub_entity_map[comp.name] = comp + if isinstance(entity, System): + for sys in entity.systems: + all_sub_entity_map[sys.name] = sys + for user in entity.users: + all_sub_entity_map[user.name] = user - for conn in entity.connections: - src_node = all_node_map.get(conn.source.entity) - tgt_node = all_node_map.get(conn.target.entity) - if src_node is None or tgt_node is None: - # Endpoint not resolvable — skip the edge rather than crashing. - continue - - src_port_id = _find_port_id(src_node, "requires", conn.interface) - tgt_port_id = _find_port_id(tgt_node, "provides", conn.interface) - - if src_port_id is None: - # Interface not explicitly declared — create an implicit port. - p = _make_port(src_node.id, "requires", conn.interface) - src_node.ports.append(p) - src_port_id = p.id - - if tgt_port_id is None: - p = _make_port(tgt_node.id, "provides", conn.interface) - tgt_node.ports.append(p) - tgt_port_id = p.id - - label = _iref_label(conn.interface) - edges.append( - VizEdge( - id=f"edge.{src_port_id}--{tgt_port_id}", - source_port_id=src_port_id, - target_port_id=tgt_port_id, - label=label, - interface_name=conn.interface.name, - interface_version=conn.interface.version, - protocol=conn.protocol, - is_async=conn.is_async, - description=conn.description, - ) - ) + # --- Edges (derived from channel bindings) --- + edges: list[VizEdge] = [] + for channel in entity.channels: + providers: list[tuple[str, InterfaceRef]] = [] + requirers: list[tuple[str, InterfaceRef]] = [] + for sub_name, sub_entity in all_sub_entity_map.items(): + for ref in sub_entity.provides: + if ref.via == channel.name: + providers.append((sub_name, ref)) + for ref in sub_entity.requires: + if ref.via == channel.name: + requirers.append((sub_name, ref)) + + for req_name, req_ref in requirers: + for prov_name, prov_ref in providers: + req_node = child_node_map.get(req_name) + prov_node = child_node_map.get(prov_name) + if req_node is None or prov_node is None: + continue + + src_port_id = _find_port_id(req_node, "requires", req_ref) + tgt_port_id = _find_port_id(prov_node, "provides", prov_ref) + + if src_port_id is None: + p = _make_port(req_node.id, "requires", req_ref) + req_node.ports.append(p) + src_port_id = p.id + + if tgt_port_id is None: + p = _make_port(prov_node.id, "provides", prov_ref) + prov_node.ports.append(p) + tgt_port_id = p.id + + label = _iref_label(channel.interface) + edges.append( + VizEdge( + id=f"edge.{src_port_id}--{tgt_port_id}", + source_port_id=src_port_id, + target_port_id=tgt_port_id, + label=label, + interface_name=channel.interface.name, + interface_version=channel.interface.version, + protocol=channel.protocol, + is_async=channel.is_async, + description=channel.description, + ) + ) return VizDiagram( id=f"diagram.{root_id}", @@ -446,42 +439,6 @@ def _make_child_node(entity: Component | System | UserDef, entity_path: str) -> ) -def _make_external_node(name: str, entity: Component | System | UserDef | None) -> VizNode: - """Create a :class:`VizNode` for an external connection endpoint. - - When *entity* is provided its full metadata is used; otherwise a minimal - stub is created so the edge can still be represented. - """ - if entity is not None: - path = entity.qualified_name or name - node_id = _make_id(path) - if isinstance(entity, Component): - kind: NodeKind = "external_component" - elif isinstance(entity, System): - kind = "external_system" - else: - kind = "external_user" - return VizNode( - id=node_id, - label=entity.name, - title=entity.title, - kind=kind, - entity_path=path, - description=entity.description, - tags=list(entity.tags), - ports=_make_ports(node_id, entity), - ) - # Stub for an endpoint whose model entity is unavailable. - node_id = f"ext.{name}" - return VizNode( - id=node_id, - label=name, - title=None, - kind="external_component", - entity_path=name, - ) - - def _make_terminal_node( ref: InterfaceRef, direction: Literal["requires", "provides"], diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index a7e9800..e4ce257 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -165,14 +165,13 @@ def test_check_reports_validation_errors( ) -> None: """check exits with code 1 when business validation finds errors.""" (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) - # Connection cycle: A -> B -> A (inline components inside system) - (tmp_path / "cycle.archml").write_text( + # Interface propagation error: system provides I but no member provides I. + (tmp_path / "propagation.archml").write_text( "interface I { field v: Int }\n" + "interface J { field v: Int }\n" "system S {\n" - " component A { provides I requires I }\n" - " component B { provides I requires I }\n" - " connect A -> B by I\n" - " connect B -> A by I\n" + " provides I\n" + " component A { provides J }\n" "}\n" ) monkeypatch.setattr(sys, "argv", ["archml", "check", str(tmp_path)]) diff --git a/tests/compiler/test_artifact.py b/tests/compiler/test_artifact.py index 4b2c0ae..14a686a 100644 --- a/tests/compiler/test_artifact.py +++ b/tests/compiler/test_artifact.py @@ -11,9 +11,8 @@ from archml.compiler.parser import parse from archml.model.entities import ( ArchFile, + ChannelDef, Component, - Connection, - ConnectionEndpoint, EnumDef, ImportDeclaration, InterfaceDef, @@ -267,16 +266,24 @@ def test_nested_component(self) -> None: result = _roundtrip(af) assert result.components[0].components[0].name == "Child" - def test_connection_roundtrip(self) -> None: + def test_channel_roundtrip(self) -> None: af = ArchFile( components=[ Component( name="Parent", - components=[Component(name="A"), Component(name="B")], - connections=[ - Connection( - source=ConnectionEndpoint(entity="A"), - target=ConnectionEndpoint(entity="B"), + components=[ + Component( + name="A", + provides=[InterfaceRef(name="Signal", via="sig_ch")], + ), + Component( + name="B", + requires=[InterfaceRef(name="Signal", via="sig_ch")], + ), + ], + channels=[ + ChannelDef( + name="sig_ch", interface=InterfaceRef(name="Signal"), protocol="gRPC", is_async=True, @@ -287,13 +294,12 @@ def test_connection_roundtrip(self) -> None: ] ) result = _roundtrip(af) - conn = result.components[0].connections[0] - assert conn.source.entity == "A" - assert conn.target.entity == "B" - assert conn.interface.name == "Signal" - assert conn.protocol == "gRPC" - assert conn.is_async - assert conn.description == "Data flow." + ch = result.components[0].channels[0] + assert ch.name == "sig_ch" + assert ch.interface.name == "Signal" + assert ch.protocol == "gRPC" + assert ch.is_async + assert ch.description == "Data flow." class TestSystems: diff --git a/tests/compiler/test_compiler_integration.py b/tests/compiler/test_compiler_integration.py index 0ba5f21..16d2e65 100644 --- a/tests/compiler/test_compiler_integration.py +++ b/tests/compiler/test_compiler_integration.py @@ -178,12 +178,12 @@ def test_undefined_interface_ref(self) -> None: "refers to unknown interface 'ExternalFeed'", ) - def test_undefined_connection_endpoint(self) -> None: + def test_undefined_via_channel(self) -> None: path = NEGATIVE_DIR / "undefined_connection_endpoint.archml" _assert_errors( path, - "connection source 'GhostProducer' is not a known member entity", - "connection target 'GhostOutput' is not a known member entity", + "ghost_ch", + "missing_ch", ) def test_wrong_interface_version(self) -> None: @@ -320,14 +320,15 @@ def test_large_types_file_parses_and_passes(self) -> None: system ECommerce { component OrderServiceInst { requires OrderRequest @v2 - requires PaymentRequest + requires PaymentRequest via payment provides OrderConfirmation } + channel payment: PaymentRequest + component PaymentGatewayInst { - requires PaymentRequest + provides PaymentRequest via payment provides PaymentResult } - connect OrderServiceInst -> PaymentGatewayInst by PaymentRequest } """ arch_file = parse(source) @@ -370,13 +371,14 @@ def test_deeply_nested_system_structure(self) -> None: system Outer { system Middle { + channel sig_ch: Signal + component Inner { - provides Signal + provides Signal via sig_ch } component Sink { - requires Signal + requires Signal via sig_ch } - connect Inner -> Sink by Signal } } """ @@ -392,15 +394,14 @@ def test_deeply_nested_with_error(self) -> None: system Outer { system Middle { component Inner { - provides Signal + provides Signal via ghost_ch } - connect Inner -> UnknownTarget by Signal } } """ arch_file = parse(source) errors = analyze(arch_file) - assert any("'UnknownTarget' is not a known member entity" in e.message for e in errors) + assert any("ghost_ch" in e.message for e in errors) def test_file_and_directory_type_refs_are_valid(self) -> None: """File and Directory types require no resolution and are always valid.""" @@ -420,7 +421,7 @@ def test_file_and_directory_type_refs_are_valid(self) -> None: assert errors == [], f"Expected clean but got: {[e.message for e in errors]}" def test_use_statement_adds_component_to_scope(self) -> None: - """Components added via 'use' are valid connection endpoints.""" + """Components added via 'use' are valid channel binding targets.""" source = """ from services import OrderService @@ -428,15 +429,16 @@ def test_use_statement_adds_component_to_scope(self) -> None: system ECommerce { use component OrderService + channel payment: PaymentRequest + component PaymentGateway { - requires PaymentRequest + requires PaymentRequest via payment } - connect OrderService -> PaymentGateway by PaymentRequest } """ arch_file = parse(source) errors = analyze(arch_file) # OrderService is in the imported names list, and 'use component' adds it - # as a stub component in the system — the connection should be valid. - conn_errors = [e for e in errors if "is not a known member entity" in e.message] - assert conn_errors == [], f"Connection endpoint errors: {conn_errors}" + # as a stub component in the system — no via binding errors expected here. + via_errors = [e for e in errors if "is not defined in this scope" in e.message] + assert via_errors == [], f"Via channel errors: {via_errors}" diff --git a/tests/compiler/test_parser.py b/tests/compiler/test_parser.py index 40b5784..e49cb3d 100644 --- a/tests/compiler/test_parser.py +++ b/tests/compiler/test_parser.py @@ -8,7 +8,7 @@ from archml.compiler.parser import ParseError, parse from archml.model.entities import ( ArchFile, - ConnectionEndpoint, + ChannelDef, ) from archml.model.types import ( DirectoryTypeRef, @@ -625,7 +625,7 @@ def test_empty_component(self) -> None: assert comp.requires == [] assert comp.provides == [] assert comp.components == [] - assert comp.connections == [] + assert comp.channels == [] def test_component_with_title(self) -> None: result = _parse('component OrderService { title = "Order Service" }') @@ -728,20 +728,21 @@ def test_deeply_nested_components(self) -> None: assert c.name == "C" assert c.requires[0].name == "X" - def test_component_with_connection(self) -> None: + def test_component_with_channel(self) -> None: source = """\ component OrderService { - component Validator { provides ValidationResult } - component Processor { requires ValidationResult } - connect Validator -> Processor by ValidationResult + channel validation: ValidationResult + component Validator { provides ValidationResult via validation } + component Processor { requires ValidationResult via validation } }""" result = _parse(source) comp = result.components[0] - assert len(comp.connections) == 1 - conn = comp.connections[0] - assert conn.source.entity == "Validator" - assert conn.target.entity == "Processor" - assert conn.interface.name == "ValidationResult" + assert len(comp.channels) == 1 + ch = comp.channels[0] + assert ch.name == "validation" + assert ch.interface.name == "ValidationResult" + assert comp.components[0].provides[0].via == "validation" + assert comp.components[1].requires[0].via == "validation" def test_external_component(self) -> None: result = _parse("external component StripeSDK { requires PaymentRequest }") @@ -778,7 +779,7 @@ def test_empty_system(self) -> None: assert system.is_external is False assert system.components == [] assert system.systems == [] - assert system.connections == [] + assert system.channels == [] def test_system_with_title(self) -> None: result = _parse('system ECommerce { title = "E-Commerce Platform" }') @@ -821,29 +822,28 @@ def test_system_with_multiple_components(self) -> None: "InventoryManager", ] - def test_system_with_connection(self) -> None: + def test_system_with_channel(self) -> None: source = """\ system ECommerce { - component A {} - component B {} - connect A -> B by Interface + channel payment: PaymentRequest + component A { provides PaymentRequest via payment } + component B { requires PaymentRequest via payment } }""" result = _parse(source) system = result.systems[0] - assert len(system.connections) == 1 - conn = system.connections[0] - assert conn.source.entity == "A" - assert conn.target.entity == "B" - assert conn.interface.name == "Interface" + assert len(system.channels) == 1 + ch = system.channels[0] + assert ch.name == "payment" + assert ch.interface.name == "PaymentRequest" - def test_system_with_multiple_connections(self) -> None: + def test_system_with_multiple_channels(self) -> None: source = """\ system ECommerce { - connect OrderService -> PaymentGateway by PaymentRequest - connect OrderService -> InventoryManager by InventoryCheck + channel payment: PaymentRequest + channel inventory: InventoryCheck }""" result = _parse(source) - assert len(result.systems[0].connections) == 2 + assert len(result.systems[0].channels) == 2 def test_system_with_nested_system(self) -> None: source = """\ @@ -933,153 +933,141 @@ def test_system_defaults(self) -> None: assert system.tags == [] assert system.is_external is False - def test_nested_system_with_connections(self) -> None: + def test_nested_system_with_channel(self) -> None: source = """\ system Enterprise { title = "Enterprise Landscape" + channel inventory: InventorySync system ECommerce {} system Warehouse {} - connect ECommerce -> Warehouse by InventorySync }""" result = _parse(source) system = result.systems[0] assert system.title == "Enterprise Landscape" assert len(system.systems) == 2 - assert len(system.connections) == 1 - assert system.connections[0].interface.name == "InventorySync" + assert len(system.channels) == 1 + assert system.channels[0].interface.name == "InventorySync" # ############### -# Connection Declarations +# Channel Declarations # ############### -class TestConnectionDeclarations: - def test_simple_connection(self) -> None: - result = _parse("""\ -system S { - connect A -> B by Interface -}""") - conn = result.systems[0].connections[0] - assert conn.source.entity == "A" - assert conn.target.entity == "B" - assert conn.interface.name == "Interface" - assert conn.interface.version is None - assert conn.protocol is None - assert conn.is_async is False - assert conn.description is None - - def test_connection_with_versioned_interface(self) -> None: - result = _parse("system S { connect A -> B by Interface @v2 }") - conn = result.systems[0].connections[0] - assert conn.interface.name == "Interface" - assert conn.interface.version == "v2" - - def test_connection_with_protocol(self) -> None: +class TestChannelDeclarations: + def test_simple_channel(self) -> None: + result = _parse("system S { channel payment: PaymentRequest }") + ch = result.systems[0].channels[0] + assert isinstance(ch, ChannelDef) + assert ch.name == "payment" + assert ch.interface.name == "PaymentRequest" + assert ch.interface.version is None + assert ch.protocol is None + assert ch.is_async is False + assert ch.description is None + + def test_channel_with_versioned_interface(self) -> None: + result = _parse("system S { channel payment: PaymentRequest @v2 }") + ch = result.systems[0].channels[0] + assert ch.interface.name == "PaymentRequest" + assert ch.interface.version == "v2" + + def test_channel_with_protocol(self) -> None: source = """\ system S { - connect A -> B by Interface { + channel payment: PaymentRequest { protocol = "gRPC" } }""" result = _parse(source) - conn = result.systems[0].connections[0] - assert conn.protocol == "gRPC" + ch = result.systems[0].channels[0] + assert ch.protocol == "gRPC" - def test_connection_with_async_true(self) -> None: + def test_channel_with_async_true(self) -> None: source = """\ system S { - connect A -> B by Interface { + channel payment: PaymentRequest { async = true } }""" result = _parse(source) - assert result.systems[0].connections[0].is_async is True + assert result.systems[0].channels[0].is_async is True - def test_connection_with_async_false(self) -> None: + def test_channel_with_async_false(self) -> None: source = """\ system S { - connect A -> B by Interface { + channel payment: PaymentRequest { async = false } }""" result = _parse(source) - assert result.systems[0].connections[0].is_async is False + assert result.systems[0].channels[0].is_async is False - def test_connection_with_description(self) -> None: + def test_channel_with_description(self) -> None: source = """\ system S { - connect A -> B by Interface { - description = "Initiates payment processing." + channel payment: PaymentRequest { + description = "Carries payment processing requests." } }""" result = _parse(source) - conn_desc = result.systems[0].connections[0].description - assert conn_desc == "Initiates payment processing." + assert result.systems[0].channels[0].description == "Carries payment processing requests." - def test_connection_with_all_annotations(self) -> None: + def test_channel_with_all_annotations(self) -> None: source = """\ system S { - connect OrderService -> PaymentGateway by PaymentRequest { + channel payment: PaymentRequest { protocol = "gRPC" async = true description = "Initiates payment processing for confirmed orders." } }""" result = _parse(source) - conn = result.systems[0].connections[0] - assert conn.source.entity == "OrderService" - assert conn.target.entity == "PaymentGateway" - assert conn.interface.name == "PaymentRequest" - assert conn.protocol == "gRPC" - assert conn.is_async is True - assert conn.description == "Initiates payment processing for confirmed orders." + ch = result.systems[0].channels[0] + assert ch.name == "payment" + assert ch.interface.name == "PaymentRequest" + assert ch.protocol == "gRPC" + assert ch.is_async is True + assert ch.description == "Initiates payment processing for confirmed orders." - def test_connection_with_http_protocol(self) -> None: + def test_channel_with_http_protocol(self) -> None: source = """\ system S { - connect A -> B by Interface { + channel inventory: InventoryCheck { protocol = "HTTP" } }""" result = _parse(source) - assert result.systems[0].connections[0].protocol == "HTTP" + assert result.systems[0].channels[0].protocol == "HTTP" - def test_multiple_connections_in_system(self) -> None: + def test_multiple_channels_in_system(self) -> None: source = """\ system ECommerce { - connect OrderService -> PaymentGateway by PaymentRequest - connect OrderService -> InventoryManager by InventoryCheck - connect PaymentGateway -> StripeAPI by PaymentRequest { - protocol = "HTTP" + channel payment: PaymentRequest { + protocol = "gRPC" async = true } + channel inventory: InventoryCheck { + protocol = "HTTP" + } + channel orders: OrderRequest }""" result = _parse(source) - conns = result.systems[0].connections - assert len(conns) == 3 - assert conns[0].interface.name == "PaymentRequest" - assert conns[1].interface.name == "InventoryCheck" - assert conns[2].protocol == "HTTP" - assert conns[2].is_async is True - - def test_connection_endpoint_entities(self) -> None: - result = _parse("system S { connect SourceService -> TargetService by MyInterface }") - conn = result.systems[0].connections[0] - assert isinstance(conn.source, ConnectionEndpoint) - assert isinstance(conn.target, ConnectionEndpoint) - assert conn.source.entity == "SourceService" - assert conn.target.entity == "TargetService" - - def test_connection_attr_on_same_line_as_lbrace_raises(self) -> None: + channels = result.systems[0].channels + assert len(channels) == 3 + assert channels[0].interface.name == "PaymentRequest" + assert channels[1].interface.name == "InventoryCheck" + assert channels[2].protocol is None + + def test_channel_attr_on_same_line_as_lbrace_raises(self) -> None: with pytest.raises(ParseError) as exc_info: - _parse('system S { connect A -> B by X { protocol = "HTTP" } }') + _parse('system S { channel payment: PaymentRequest { protocol = "HTTP" } }') assert "new line" in str(exc_info.value) - def test_connection_attrs_on_same_line_as_each_other_raises(self) -> None: + def test_channel_attrs_on_same_line_as_each_other_raises(self) -> None: source = """\ system S { - connect A -> B by X { + channel payment: PaymentRequest { protocol = "HTTP" async = true } }""" @@ -1087,6 +1075,42 @@ def test_connection_attrs_on_same_line_as_each_other_raises(self) -> None: _parse(source) assert "new line" in str(exc_info.value) + def test_requires_with_via(self) -> None: + result = _parse("component X { requires PaymentRequest via payment }") + ref = result.components[0].requires[0] + assert ref.name == "PaymentRequest" + assert ref.via == "payment" + + def test_provides_with_via(self) -> None: + result = _parse("component X { provides OrderConfirmation via confirm }") + ref = result.components[0].provides[0] + assert ref.name == "OrderConfirmation" + assert ref.via == "confirm" + + def test_requires_versioned_with_via(self) -> None: + result = _parse("component X { requires PaymentRequest @v2 via payment }") + ref = result.components[0].requires[0] + assert ref.name == "PaymentRequest" + assert ref.version == "v2" + assert ref.via == "payment" + + def test_requires_without_via_has_none(self) -> None: + result = _parse("component X { requires PaymentRequest }") + ref = result.components[0].requires[0] + assert ref.via is None + + def test_channel_in_component(self) -> None: + source = """\ +component OrderService { + channel validation: ValidationResult + component Validator { provides ValidationResult via validation } + component Processor { requires ValidationResult via validation } +}""" + result = _parse(source) + comp = result.components[0] + assert len(comp.channels) == 1 + assert comp.channels[0].name == "validation" + # ############### # Multi-Line Descriptions @@ -1418,31 +1442,30 @@ def test_complete_spec_example_ecommerce_system(self) -> None: system ECommerce { title = "E-Commerce Platform" + channel payment: PaymentRequest { + protocol = "HTTP" + async = true + } + channel inventory: InventoryCheck { + protocol = "HTTP" + } + use component OrderService component PaymentGateway { title = "Payment Gateway" tags = ["critical", "pci-scope"] - requires PaymentRequest + requires PaymentRequest via payment provides PaymentResult } component InventoryManager { title = "Inventory Manager" - requires InventoryCheck + requires InventoryCheck via inventory provides InventoryStatus } - - connect OrderService -> PaymentGateway by PaymentRequest - connect OrderService -> InventoryManager by InventoryCheck { - protocol = "HTTP" - } - connect PaymentGateway -> StripeAPI by PaymentRequest { - protocol = "HTTP" - async = true - } } """ result = _parse(source) @@ -1457,55 +1480,54 @@ def test_complete_spec_example_ecommerce_system(self) -> None: ecommerce = next(s for s in result.systems if s.name == "ECommerce") assert ecommerce.title == "E-Commerce Platform" assert len(ecommerce.components) == 3 # use + 2 inline + assert len(ecommerce.channels) == 2 gw = next(c for c in ecommerce.components if c.name == "PaymentGateway") assert gw.tags == ["critical", "pci-scope"] - assert len(ecommerce.connections) == 3 - last_conn = ecommerce.connections[2] - assert last_conn.protocol == "HTTP" - assert last_conn.is_async is True + payment_ch = ecommerce.channels[0] + assert payment_ch.protocol == "HTTP" + assert payment_ch.is_async is True - def test_nested_component_with_connections(self) -> None: - """Parse a component with nested sub-components and connections.""" + def test_nested_component_with_channels(self) -> None: + """Parse a component with nested sub-components and internal channel.""" source = """\ component OrderService { title = "Order Service" + channel validation: ValidationResult + component Validator { title = "Order Validator" requires OrderRequest - provides ValidationResult + provides ValidationResult via validation } component Processor { title = "Order Processor" - requires ValidationResult + requires ValidationResult via validation requires PaymentRequest requires InventoryCheck provides OrderConfirmation } - - connect Validator -> Processor by ValidationResult } """ result = _parse(source) comp = result.components[0] assert comp.name == "OrderService" assert len(comp.components) == 2 - assert len(comp.connections) == 1 + assert len(comp.channels) == 1 validator = comp.components[0] assert validator.name == "Validator" assert validator.requires[0].name == "OrderRequest" assert validator.provides[0].name == "ValidationResult" + assert validator.provides[0].via == "validation" - conn = comp.connections[0] - assert conn.source.entity == "Validator" - assert conn.target.entity == "Processor" - assert conn.interface.name == "ValidationResult" + processor = comp.components[1] + assert processor.requires[0].via == "validation" def test_enterprise_nested_systems(self) -> None: """Parse an enterprise system with nested sub-systems.""" @@ -1513,32 +1535,37 @@ def test_enterprise_nested_systems(self) -> None: system Enterprise { title = "Enterprise Landscape" + channel inventory: InventorySync + system ECommerce {} system Warehouse {} - - connect ECommerce -> Warehouse by InventorySync } """ result = _parse(source) enterprise = result.systems[0] assert enterprise.title == "Enterprise Landscape" assert len(enterprise.systems) == 2 - assert len(enterprise.connections) == 1 - assert enterprise.connections[0].interface.name == "InventorySync" + assert len(enterprise.channels) == 1 + assert enterprise.channels[0].interface.name == "InventorySync" - def test_bidirectional_connections(self) -> None: - """Two connections simulate bidirectional communication.""" + def test_multiple_via_bindings(self) -> None: + """Components can bind to multiple channels independently.""" source = """\ system S { - connect ServiceA -> ServiceB by RequestToB - connect ServiceB -> ServiceA by ResponseToA + channel payment: PaymentRequest + channel inventory: InventoryCheck + component OrderService { + requires PaymentRequest via payment + requires InventoryCheck via inventory + provides OrderConfirmation + } } """ result = _parse(source) - conns = result.systems[0].connections - assert len(conns) == 2 - assert conns[0].source.entity == "ServiceA" - assert conns[1].source.entity == "ServiceB" + comp = result.systems[0].components[0] + assert comp.requires[0].via == "payment" + assert comp.requires[1].via == "inventory" + assert comp.provides[0].via is None # ############### @@ -1620,21 +1647,13 @@ def test_use_followed_by_invalid_keyword(self) -> None: _parse("system S { use interface I }") assert "Expected" in str(exc_info.value) - def test_connect_missing_arrow(self) -> None: - with pytest.raises(ParseError): - _parse("system S { connect A B by Interface }") - - def test_connect_missing_by(self) -> None: - with pytest.raises(ParseError): - _parse("system S { connect A -> B Interface }") - - def test_connect_missing_target(self) -> None: + def test_channel_missing_colon(self) -> None: with pytest.raises(ParseError): - _parse("system S { connect A -> by Interface }") + _parse("system S { channel payment PaymentRequest }") - def test_connect_missing_interface(self) -> None: + def test_channel_missing_interface(self) -> None: with pytest.raises(ParseError): - _parse("system S { connect A -> B by }") + _parse("system S { channel payment: }") def test_field_missing_colon(self) -> None: with pytest.raises(ParseError): @@ -1688,10 +1707,10 @@ def test_import_missing_import_keyword(self) -> None: with pytest.raises(ParseError): _parse("from interfaces/order X") - def test_unknown_connection_attribute(self) -> None: + def test_unknown_channel_attribute(self) -> None: with pytest.raises(ParseError) as exc_info: - _parse("system S {\n connect A -> B by I {\n timeout = 30\n }\n}") - assert "Unknown connection attribute" in str(exc_info.value) + _parse("system S {\n channel payment: PaymentRequest {\n timeout = 30\n }\n}") + assert "Unknown channel attribute" in str(exc_info.value) def test_unknown_field_annotation(self) -> None: with pytest.raises(ParseError): @@ -1735,10 +1754,10 @@ def test_interface_version_with_alphanumeric(self) -> None: result = _parse("interface X @v10 {}") assert result.interfaces[0].version == "v10" - def test_connection_interface_version(self) -> None: - result = _parse("system S { connect A -> B by Interface @v2 }") - conn = result.systems[0].connections[0] - assert conn.interface.version == "v2" + def test_channel_interface_version(self) -> None: + result = _parse("system S { channel feed: DataFeed @v2 }") + ch = result.systems[0].channels[0] + assert ch.interface.version == "v2" def test_requires_with_version(self) -> None: result = _parse("component X { requires Interface @v2 }") @@ -1906,12 +1925,12 @@ def test_map_with_named_key_and_value(self) -> None: assert isinstance(map_type.value_type, NamedTypeRef) assert map_type.value_type.name == "OrderItem" - def test_connection_without_annotation_block(self) -> None: - result = _parse("system S { connect A -> B by X }") - conn = result.systems[0].connections[0] - assert conn.protocol is None - assert conn.is_async is False - assert conn.description is None + def test_channel_without_annotation_block(self) -> None: + result = _parse("system S { channel payment: PaymentRequest }") + ch = result.systems[0].channels[0] + assert ch.protocol is None + assert ch.is_async is False + assert ch.description is None def test_interface_field_empty_annotation_block(self) -> None: source = "interface I { field x: String {} }" @@ -2014,9 +2033,9 @@ def test_user_body_disallows_component_keyword(self) -> None: with pytest.raises(ParseError): _parse("user Customer { component Sub {} }") - def test_user_body_disallows_connect_keyword(self) -> None: + def test_user_body_disallows_channel_keyword(self) -> None: with pytest.raises(ParseError): - _parse("user Customer { connect A -> B by X }") + _parse("user Customer { channel payment: PaymentRequest }") def test_external_invalid_keyword_after_raises(self) -> None: with pytest.raises(ParseError, match="Expected 'component', 'system', or 'user'"): diff --git a/tests/compiler/test_scanner.py b/tests/compiler/test_scanner.py index 5fcc958..8fd04c7 100644 --- a/tests/compiler/test_scanner.py +++ b/tests/compiler/test_scanner.py @@ -82,8 +82,8 @@ class TestKeywords: ("schema", TokenType.SCHEMA), ("requires", TokenType.REQUIRES), ("provides", TokenType.PROVIDES), - ("connect", TokenType.CONNECT), - ("by", TokenType.BY), + ("channel", TokenType.CHANNEL), + ("via", TokenType.VIA), ("from", TokenType.FROM), ("import", TokenType.IMPORT), ("use", TokenType.USE), @@ -178,24 +178,10 @@ def test_single_char_symbol(self, source: str, expected_type: TokenType) -> None assert tokens[0].type == expected_type assert tokens[0].value == source - def test_arrow_operator(self) -> None: - tokens = _tokens_no_eof("->") - assert len(tokens) == 1 - assert tokens[0].type == TokenType.ARROW - assert tokens[0].value == "->" - - def test_arrow_within_connect_statement(self) -> None: - types = _types("A -> B") - assert types == [TokenType.IDENTIFIER, TokenType.ARROW, TokenType.IDENTIFIER] - - def test_dash_without_arrow_raises(self) -> None: + def test_dash_raises(self) -> None: with pytest.raises(LexerError): tokenize("-") - def test_dash_followed_by_non_arrow_raises(self) -> None: - with pytest.raises(LexerError): - tokenize("- ") - def test_generic_type_angle_brackets(self) -> None: types = _types("List") assert types == [ @@ -578,12 +564,12 @@ def test_token_after_multiline_block_comment(self) -> None: assert tokens[0].line == 3 assert tokens[0].column == 4 - def test_arrow_start_position(self) -> None: - tokens = _tokens_no_eof("A -> B") - arrow = tokens[1] - assert arrow.type == TokenType.ARROW - assert arrow.line == 1 - assert arrow.column == 3 + def test_via_keyword_position(self) -> None: + tokens = _tokens_no_eof("requires X via ch") + via_tok = tokens[2] + assert via_tok.type == TokenType.VIA + assert via_tok.line == 1 + assert via_tok.column == 12 def test_string_start_position_is_at_opening_quote(self) -> None: tokens = _tokens_no_eof('x = "hello"') @@ -739,15 +725,23 @@ def test_optional_type(self) -> None: TokenType.RANGLE, ] - def test_connect_statement(self) -> None: - source = "connect OrderService -> PaymentGateway by PaymentRequest" + def test_channel_declaration(self) -> None: + source = "channel payment: PaymentRequest" types = _types(source) assert types == [ - TokenType.CONNECT, + TokenType.CHANNEL, TokenType.IDENTIFIER, - TokenType.ARROW, + TokenType.COLON, TokenType.IDENTIFIER, - TokenType.BY, + ] + + def test_requires_via_declaration(self) -> None: + source = "requires PaymentRequest via payment" + types = _types(source) + assert types == [ + TokenType.REQUIRES, + TokenType.IDENTIFIER, + TokenType.VIA, TokenType.IDENTIFIER, ] @@ -840,20 +834,18 @@ def test_schema_annotation(self) -> None: types = _types(source) assert types == [TokenType.SCHEMA, TokenType.EQUALS, TokenType.STRING] - def test_connect_with_block_annotation(self) -> None: + def test_channel_with_block_annotation(self) -> None: source = """ - connect A -> B by X { + channel payment: PaymentRequest { protocol = "HTTP" async = true } """ types = _types(source) assert types == [ - TokenType.CONNECT, - TokenType.IDENTIFIER, - TokenType.ARROW, + TokenType.CHANNEL, TokenType.IDENTIFIER, - TokenType.BY, + TokenType.COLON, TokenType.IDENTIFIER, TokenType.LBRACE, TokenType.IDENTIFIER, @@ -954,27 +946,26 @@ def test_interface_block(self) -> None: assert "Order Creation Request" in string_values assert "Payload for submitting a new customer order." in string_values - def test_system_with_connections(self) -> None: + def test_system_with_channels(self) -> None: source = """ system ECommerce { title = "E-Commerce Platform" + channel payment: PaymentRequest + component PaymentGateway { tags = ["critical", "pci-scope"] - requires PaymentRequest + requires PaymentRequest via payment provides PaymentResult } - - connect OrderService -> PaymentGateway by PaymentRequest } """ tokens = _tokens_no_eof(source) assert tokens[0].type == TokenType.SYSTEM - # Find the ARROW and BY tokens - arrow_tokens = [t for t in tokens if t.type == TokenType.ARROW] - by_tokens = [t for t in tokens if t.type == TokenType.BY] - assert len(arrow_tokens) == 1 - assert len(by_tokens) == 1 + channel_tokens = [t for t in tokens if t.type == TokenType.CHANNEL] + via_tokens = [t for t in tokens if t.type == TokenType.VIA] + assert len(channel_tokens) == 1 + assert len(via_tokens) == 1 def test_file_field_annotation(self) -> None: source = """ diff --git a/tests/compiler/test_semantic_analysis.py b/tests/compiler/test_semantic_analysis.py index bc9a08f..a402c8a 100644 --- a/tests/compiler/test_semantic_analysis.py +++ b/tests/compiler/test_semantic_analysis.py @@ -7,9 +7,8 @@ from archml.compiler.semantic_analysis import SemanticError, analyze from archml.model.entities import ( ArchFile, + ChannelDef, Component, - Connection, - ConnectionEndpoint, EnumDef, InterfaceDef, InterfaceRef, @@ -114,41 +113,41 @@ def test_interface_with_known_type_field(self) -> None: } """) - def test_system_with_components_and_connection(self) -> None: + def test_system_with_components_and_channel(self) -> None: _assert_clean(""" interface DataFeed { field payload: String } system Pipeline { + channel feed: DataFeed + component Producer { - provides DataFeed + provides DataFeed via feed } component Consumer { - requires DataFeed + requires DataFeed via feed } - - connect Producer -> Consumer by DataFeed } """) - def test_nested_components_with_connection(self) -> None: + def test_nested_components_with_channel(self) -> None: _assert_clean(""" interface Signal { field value: Bool } component Router { + channel sig: Signal + component Input { - provides Signal + provides Signal via sig } component Output { - requires Signal + requires Signal via sig } - - connect Input -> Output by Signal } """) @@ -675,118 +674,120 @@ def test_system_with_undefined_provides(self) -> None: # ############### -# Connection Endpoint Validation +# Channel Validation # ############### -class TestConnectionEndpoints: - def test_unknown_source_in_component_connection(self) -> None: - _assert_error( - """ -interface Signal { field v: Bool } -component Router { - component Output { requires Signal } - connect UnknownInput -> Output by Signal +class TestChannelValidation: + def test_valid_system_channel(self) -> None: + _assert_clean(""" +interface DataFeed { field payload: String } +system Pipeline { + channel feed: DataFeed + component Producer { provides DataFeed via feed } + component Consumer { requires DataFeed via feed } } -""", - "connection source 'UnknownInput' is not a known member entity", - ) +""") + + def test_valid_component_channel(self) -> None: + _assert_clean(""" +interface Signal { field value: Int } +component Processor { + channel sig: Signal + component Source { provides Signal via sig } + component Sink { requires Signal via sig } +} +""") - def test_unknown_target_in_component_connection(self) -> None: + def test_channel_with_undefined_interface(self) -> None: _assert_error( """ -interface Signal { field v: Bool } -component Router { - component Input { provides Signal } - connect Input -> UnknownOutput by Signal +system Pipeline { + channel feed: UndefinedInterface } """, - "connection target 'UnknownOutput' is not a known member entity", + "refers to unknown interface 'UndefinedInterface'", ) - def test_unknown_source_in_system_connection(self) -> None: + def test_via_binding_to_unknown_channel(self) -> None: _assert_error( """ interface DataFeed { field payload: String } system Pipeline { - component Consumer { requires DataFeed } - connect MissingProducer -> Consumer by DataFeed + component Producer { provides DataFeed via nonexistent } } """, - "connection source 'MissingProducer' is not a known member entity", + "channel 'nonexistent' is not defined in this scope", ) - def test_unknown_target_in_system_connection(self) -> None: + def test_via_binding_in_component_to_unknown_channel(self) -> None: _assert_error( """ -interface DataFeed { field payload: String } -system Pipeline { - component Producer { provides DataFeed } - connect Producer -> MissingConsumer by DataFeed +interface Signal { field v: Bool } +component Router { + component Output { requires Signal via unknown_channel } } """, - "connection target 'MissingConsumer' is not a known member entity", + "channel 'unknown_channel' is not defined in this scope", ) - def test_valid_system_connection(self) -> None: + def test_valid_via_binding_to_parent_system_channel(self) -> None: _assert_clean(""" interface DataFeed { field payload: String } system Pipeline { - component Producer { provides DataFeed } - component Consumer { requires DataFeed } - connect Producer -> Consumer by DataFeed + channel feed: DataFeed + component Producer { provides DataFeed via feed } + component Consumer { requires DataFeed via feed } } """) - def test_valid_component_connection(self) -> None: + def test_channel_with_versioned_interface_ok(self) -> None: _assert_clean(""" -interface Signal { field value: Int } -component Processor { - component Source { provides Signal } - component Sink { requires Signal } - connect Source -> Sink by Signal +interface Feed @v1 { field data: String } +system Pipeline { + channel feed: Feed @v1 + component A { provides Feed @v1 via feed } + component B { requires Feed @v1 via feed } } """) - def test_connection_with_undefined_interface(self) -> None: + def test_duplicate_channel_names_in_system(self) -> None: _assert_error( """ -system Pipeline { - component A {} - component B {} - connect A -> B by UndefinedInterface +interface X {} +system S { + channel ch: X + channel ch: X } """, - "refers to unknown interface 'UndefinedInterface'", + "Duplicate channel name 'ch'", ) - def test_connection_with_versioned_interface_ok(self) -> None: - _assert_clean(""" -interface Feed @v1 { field data: String } -system Pipeline { - component A { provides Feed @v1 } - component B { requires Feed @v1 } - connect A -> B by Feed @v1 + def test_duplicate_channel_names_in_component(self) -> None: + _assert_error( + """ +interface X {} +component C { + channel ch: X + channel ch: X } -""") +""", + "Duplicate channel name 'ch'", + ) - def test_connection_endpoint_can_be_sub_system(self) -> None: + def test_requires_without_via_is_valid(self) -> None: _assert_clean(""" -interface API { field endpoint: String } -system Enterprise { - system Frontend { provides API } - system Backend { requires API } - connect Frontend -> Backend by API -} +interface DataFeed { field payload: String } +component Producer { provides DataFeed } """) - def test_external_component_valid_connection_endpoint(self) -> None: + def test_channel_in_component_scope(self) -> None: _assert_clean(""" -interface PayReq { field amount: Decimal } -system ECommerce { - component OrderService { provides PayReq } - external component StripeAPI { requires PayReq } - connect OrderService -> StripeAPI by PayReq +interface Signal { field value: Bool } +component Router { + channel sig: Signal + component Input { provides Signal via sig } + component Output { requires Signal via sig } } """) @@ -952,22 +953,16 @@ def test_duplicate_enum_names_in_model(self) -> None: errors = analyze(arch_file) assert any("Duplicate enum name 'Status'" in e.message for e in errors) - def test_connection_with_known_interface_model(self) -> None: + def test_channel_with_known_interface_model(self) -> None: arch_file = ArchFile( interfaces=[InterfaceDef(name="Signal", version=None)], systems=[ System( name="Sys", + channels=[ChannelDef(name="sig", interface=InterfaceRef(name="Signal"))], components=[ - Component(name="A"), - Component(name="B"), - ], - connections=[ - Connection( - source=ConnectionEndpoint(entity="A"), - target=ConnectionEndpoint(entity="B"), - interface=InterfaceRef(name="Signal"), - ) + Component(name="A", provides=[InterfaceRef(name="Signal", via="sig")]), + Component(name="B", requires=[InterfaceRef(name="Signal", via="sig")]), ], ) ], @@ -1317,27 +1312,21 @@ def test_user_name_conflicts_with_system_in_system(self) -> None: "name 'Sub' is used for both a user and a component or sub-system", ) - def test_user_as_connection_endpoint_in_system(self) -> None: + def test_user_provides_with_via_in_system(self) -> None: _assert_clean(""" interface OrderRequest {} -user Customer { provides OrderRequest } -component OrderService { requires OrderRequest } system S { - use user Customer - use component OrderService - connect Customer -> OrderService by OrderRequest + channel orders: OrderRequest + user Customer { provides OrderRequest via orders } + component OrderService { requires OrderRequest via orders } } """) - def test_top_level_user_as_connection_endpoint(self) -> None: + def test_user_without_via_is_valid(self) -> None: _assert_clean(""" interface I {} user A { provides I } component B { requires I } -system S { - use component B - connect A -> B by I -} """) def test_user_qualified_name_top_level(self) -> None: diff --git a/tests/data/negative/undefined_connection_endpoint.archml b/tests/data/negative/undefined_connection_endpoint.archml index a62334e..769d891 100644 --- a/tests/data/negative/undefined_connection_endpoint.archml +++ b/tests/data/negative/undefined_connection_endpoint.archml @@ -1,7 +1,7 @@ -// Negative: connection endpoints reference non-existent members +// Negative: via references point to non-existent channels // Expected errors: -// system 'Pipeline': connection source 'GhostProducer' is not a known member entity -// component 'Router': connection target 'GhostOutput' is not a known member entity +// component 'Processor': via binding 'ghost_ch' refers to unknown channel +// component 'Worker': via binding 'missing_ch' refers to unknown channel interface DataFeed { field payload: String @@ -12,19 +12,17 @@ interface Signal { } system Pipeline { + channel data_in: DataFeed + component Consumer { - requires DataFeed + requires DataFeed via ghost_ch } - - // GhostProducer is not defined as a member - connect GhostProducer -> Consumer by DataFeed } component Router { + channel signal_ch: Signal + component Input { - provides Signal + provides Signal via missing_ch } - - // GhostOutput is not defined as a sub-component - connect Input -> GhostOutput by Signal } diff --git a/tests/data/positive/compiler/simple.archml b/tests/data/positive/compiler/simple.archml index 9277bc1..5f84ad7 100644 --- a/tests/data/positive/compiler/simple.archml +++ b/tests/data/positive/compiler/simple.archml @@ -26,12 +26,13 @@ component Worker { } system SimpleSystem { + channel data_req: DataRequest + component Client { - requires DataRequest + requires DataRequest via data_req } component WorkerInst { - requires DataRequest + requires DataRequest via data_req provides DataResponse } - connect Client -> WorkerInst by DataRequest } diff --git a/tests/data/positive/compiler/system.archml b/tests/data/positive/compiler/system.archml index ef17d39..4e1f26f 100644 --- a/tests/data/positive/compiler/system.archml +++ b/tests/data/positive/compiler/system.archml @@ -6,10 +6,10 @@ from compiler/worker import TaskWorker, PriorityRouter system TaskProcessingSystem { use component TaskWorker use component PriorityRouter + channel task_req: TaskRequest + component Dispatcher { requires TaskRequest - provides TaskRequest + provides TaskRequest via task_req } - connect Dispatcher -> PriorityRouter by TaskRequest - connect PriorityRouter -> TaskWorker by TaskRequest } diff --git a/tests/data/positive/imports/ecommerce_system.archml b/tests/data/positive/imports/ecommerce_system.archml index f42b31c..77df156 100644 --- a/tests/data/positive/imports/ecommerce_system.archml +++ b/tests/data/positive/imports/ecommerce_system.archml @@ -22,17 +22,18 @@ system ECommerce { use component OrderService + channel payment: PaymentRequest + channel stripe: PaymentRequest { + protocol = "HTTP" + async = true + } + component PaymentGateway { title = "Payment Gateway" tags = ["critical", "pci-scope"] - requires PaymentRequest + requires PaymentRequest via payment provides PaymentResult - } - - connect OrderService -> PaymentGateway by PaymentRequest - connect PaymentGateway -> StripeAPI by PaymentRequest { - protocol = "HTTP" - async = true + requires PaymentRequest via stripe } } diff --git a/tests/data/positive/nested_components.archml b/tests/data/positive/nested_components.archml index aff5142..fdc9f67 100644 --- a/tests/data/positive/nested_components.archml +++ b/tests/data/positive/nested_components.archml @@ -31,22 +31,22 @@ component OrderService { requires PaymentRequest provides OrderConfirmation + channel validation: ValidationResult + component Validator { title = "Order Validator" description = "Validates incoming order requests." requires OrderRequest - provides ValidationResult + provides ValidationResult via validation } component Processor { title = "Order Processor" description = "Processes validated orders and triggers payment." - requires ValidationResult + requires ValidationResult via validation requires PaymentRequest provides OrderConfirmation } - - connect Validator -> Processor by ValidationResult } diff --git a/tests/data/positive/system_with_connections.archml b/tests/data/positive/system_with_connections.archml index e7cbd83..41e88af 100644 --- a/tests/data/positive/system_with_connections.archml +++ b/tests/data/positive/system_with_connections.archml @@ -46,13 +46,28 @@ system ECommerce { title = "E-Commerce Platform" description = "Customer-facing online store back-end." + channel payment: PaymentRequest { + protocol = "gRPC" + async = false + description = "Submit payment for confirmed order." + } + channel inventory: InventoryCheck { + protocol = "HTTP" + async = true + } + channel stripe_payment: PaymentRequest { + protocol = "HTTP" + async = true + description = "Delegate payment processing to Stripe." + } + component OrderService { title = "Order Service" tags = ["core"] requires OrderRequest - requires PaymentRequest - requires InventoryCheck + requires PaymentRequest via payment + requires InventoryCheck via inventory provides OrderConfirmation } @@ -60,29 +75,15 @@ system ECommerce { title = "Payment Gateway" tags = ["critical", "pci-scope"] - requires PaymentRequest + provides PaymentRequest via payment + requires PaymentRequest via stripe_payment provides PaymentResult } component InventoryManager { title = "Inventory Manager" - requires InventoryCheck + provides InventoryCheck via inventory provides InventoryStatus } - - connect OrderService -> PaymentGateway by PaymentRequest { - protocol = "gRPC" - async = false - description = "Submit payment for confirmed order." - } - connect OrderService -> InventoryManager by InventoryCheck { - protocol = "HTTP" - async = true - } - connect PaymentGateway -> StripeAPI by PaymentRequest { - protocol = "HTTP" - async = true - description = "Delegate payment processing to Stripe." - } } diff --git a/tests/model/test_model.py b/tests/model/test_model.py index afe40bc..c921503 100644 --- a/tests/model/test_model.py +++ b/tests/model/test_model.py @@ -5,9 +5,8 @@ from archml.model import ( ArchFile, + ChannelDef, Component, - Connection, - ConnectionEndpoint, DirectoryTypeRef, EnumDef, FieldDef, @@ -185,78 +184,71 @@ def test_external_component() -> None: assert ext.is_external -def test_connection() -> None: - """A Connection links a required interface to a provided interface.""" - conn = Connection( - source=ConnectionEndpoint(entity="OrderService"), - target=ConnectionEndpoint(entity="PaymentGateway"), +def test_channel_definition() -> None: + """A ChannelDef is a named conduit carrying a specific interface.""" + ch = ChannelDef( + name="payment", interface=InterfaceRef(name="PaymentRequest"), protocol="gRPC", is_async=True, description="Initiates payment processing.", ) - assert conn.source.entity == "OrderService" - assert conn.target.entity == "PaymentGateway" - assert conn.interface.name == "PaymentRequest" - assert conn.protocol == "gRPC" - assert conn.is_async + assert ch.name == "payment" + assert ch.interface.name == "PaymentRequest" + assert ch.protocol == "gRPC" + assert ch.is_async + + +def test_interface_ref_with_via() -> None: + """An InterfaceRef can carry an optional via binding to a named channel.""" + ref = InterfaceRef(name="PaymentRequest", via="payment") + assert ref.name == "PaymentRequest" + assert ref.via == "payment" def test_nested_component() -> None: - """A Component can contain sub-components with internal connections.""" + """A Component can contain sub-components connected through channels.""" validator = Component( name="Validator", requires=[InterfaceRef(name="OrderRequest")], - provides=[InterfaceRef(name="ValidationResult")], + provides=[InterfaceRef(name="ValidationResult", via="validation")], ) processor = Component( name="Processor", - requires=[InterfaceRef(name="ValidationResult"), InterfaceRef(name="PaymentRequest")], + requires=[InterfaceRef(name="ValidationResult", via="validation"), InterfaceRef(name="PaymentRequest")], provides=[InterfaceRef(name="OrderConfirmation")], ) - conn = Connection( - source=ConnectionEndpoint(entity="Validator"), - target=ConnectionEndpoint(entity="Processor"), - interface=InterfaceRef(name="ValidationResult"), - ) order_svc = Component( name="OrderService", + channels=[ChannelDef(name="validation", interface=InterfaceRef(name="ValidationResult"))], components=[validator, processor], - connections=[conn], ) assert len(order_svc.components) == 2 - assert len(order_svc.connections) == 1 + assert len(order_svc.channels) == 1 assert order_svc.components[0].name == "Validator" -def test_system_with_components_and_connections() -> None: - """A System groups components and declares connections between them.""" +def test_system_with_components_and_channels() -> None: + """A System groups components connected through named channels.""" order_svc = Component( name="OrderService", - requires=[InterfaceRef(name="PaymentRequest"), InterfaceRef(name="InventoryCheck")], + requires=[InterfaceRef(name="PaymentRequest", via="payment"), InterfaceRef(name="InventoryCheck")], provides=[InterfaceRef(name="OrderConfirmation")], ) payment_gw = Component( name="PaymentGateway", tags=["critical", "pci-scope"], - requires=[InterfaceRef(name="PaymentRequest")], - provides=[InterfaceRef(name="PaymentResult")], + provides=[InterfaceRef(name="PaymentRequest", via="payment")], ) ecommerce = System( name="ECommerce", title="E-Commerce Platform", + channels=[ChannelDef(name="payment", interface=InterfaceRef(name="PaymentRequest"))], components=[order_svc, payment_gw], - connections=[ - Connection( - source=ConnectionEndpoint(entity="OrderService"), - target=ConnectionEndpoint(entity="PaymentGateway"), - interface=InterfaceRef(name="PaymentRequest"), - ) - ], ) assert ecommerce.name == "ECommerce" assert len(ecommerce.components) == 2 - assert len(ecommerce.connections) == 1 + assert len(ecommerce.channels) == 1 assert not ecommerce.is_external @@ -267,13 +259,6 @@ def test_nested_systems() -> None: enterprise = System( name="Enterprise", systems=[ecommerce, warehouse], - connections=[ - Connection( - source=ConnectionEndpoint(entity="ECommerce"), - target=ConnectionEndpoint(entity="Warehouse"), - interface=InterfaceRef(name="InventorySync"), - ) - ], ) assert len(enterprise.systems) == 2 assert enterprise.systems[0].name == "ECommerce" diff --git a/tests/validation/test_checks.py b/tests/validation/test_checks.py index 1a34435..674e25f 100644 --- a/tests/validation/test_checks.py +++ b/tests/validation/test_checks.py @@ -5,9 +5,8 @@ from archml.model.entities import ( ArchFile, + ChannelDef, Component, - Connection, - ConnectionEndpoint, InterfaceDef, InterfaceRef, System, @@ -33,27 +32,14 @@ # ############### -def _iref(name: str, version: str | None = None) -> InterfaceRef: +def _iref(name: str, version: str | None = None, via: str | None = None) -> InterfaceRef: """Create an InterfaceRef.""" - return InterfaceRef(name=name, version=version) + return InterfaceRef(name=name, version=version, via=via) -def _conn(source: str, target: str, interface: str = "I") -> Connection: - """Create a Connection between two named entities.""" - return Connection( - source=ConnectionEndpoint(entity=source), - target=ConnectionEndpoint(entity=target), - interface=InterfaceRef(name=interface), - ) - - -def _conn_v(source: str, target: str, interface: str, version: str) -> Connection: - """Create a Connection with a versioned interface.""" - return Connection( - source=ConnectionEndpoint(entity=source), - target=ConnectionEndpoint(entity=target), - interface=InterfaceRef(name=interface, version=version), - ) +def _channel(name: str, interface: str, version: str | None = None) -> ChannelDef: + """Create a ChannelDef.""" + return ChannelDef(name=name, interface=InterfaceRef(name=interface, version=version)) def _pfield(name: str) -> FieldDef: @@ -123,140 +109,13 @@ def _assert_no_error(arch_file: ArchFile) -> None: assert result.errors == [], f"Expected no errors but got: {_errors(result)}" -# ############### -# Connection Cycles -# ############### - - -class TestConnectionCycles: - """Check 2: Cycles in the connection graph within any scope are errors.""" - - def test_no_connections_no_error(self) -> None: - arch = ArchFile(systems=[System(name="S", provides=[_iref("I")])]) - _assert_no_error(arch) - - def test_direct_cycle_in_system(self) -> None: - # A -> B -> A within a system's connections - sys_ = System( - name="S", - provides=[_iref("I")], - connections=[_conn("A", "B"), _conn("B", "A")], - ) - arch = ArchFile(systems=[sys_]) - _assert_error(arch, "Connection cycle") - _assert_error(arch, "'S'") - - def test_three_node_cycle_in_system(self) -> None: - sys_ = System( - name="S", - provides=[_iref("I")], - connections=[_conn("A", "B"), _conn("B", "C"), _conn("C", "A")], - ) - arch = ArchFile(systems=[sys_]) - _assert_error(arch, "Connection cycle") - - def test_self_loop(self) -> None: - sys_ = System( - name="S", - provides=[_iref("I")], - connections=[_conn("A", "A")], - ) - arch = ArchFile(systems=[sys_]) - _assert_error(arch, "Connection cycle") - - def test_linear_connections_no_cycle(self) -> None: - sys_ = System( - name="S", - provides=[_iref("I")], - connections=[_conn("A", "B"), _conn("B", "C")], - ) - arch = ArchFile(systems=[sys_]) - _assert_no_error(arch) - - def test_diamond_no_cycle(self) -> None: - # A -> B, A -> C, B -> D, C -> D — acyclic diamond - sys_ = System( - name="S", - provides=[_iref("I")], - connections=[ - _conn("A", "B"), - _conn("A", "C"), - _conn("B", "D"), - _conn("C", "D"), - ], - ) - arch = ArchFile(systems=[sys_]) - _assert_no_error(arch) - - def test_cycle_in_component_scope(self) -> None: - comp = Component( - name="C", - provides=[_iref("I")], - connections=[_conn("X", "Y"), _conn("Y", "X")], - ) - arch = ArchFile(components=[comp]) - _assert_error(arch, "Connection cycle") - _assert_error(arch, "'C'") - - def test_cycle_in_nested_subsystem(self) -> None: - inner = System( - name="Inner", - provides=[_iref("I")], - connections=[_conn("P", "Q"), _conn("Q", "P")], - ) - outer = System(name="Outer", provides=[_iref("I")], systems=[inner]) - arch = ArchFile(systems=[outer]) - _assert_error(arch, "Connection cycle") - _assert_error(arch, "Inner") - - def test_parent_acyclic_child_cyclic_reports_child(self) -> None: - inner = System( - name="Inner", - provides=[_iref("I")], - connections=[_conn("X", "Y"), _conn("Y", "X")], - ) - outer = System( - name="Outer", - provides=[_iref("I")], - systems=[inner], - connections=[_conn("Inner", "Other")], # acyclic at outer level - ) - arch = ArchFile(systems=[outer]) - result = validate(arch) - msgs = _errors(result) - assert any("Inner" in m for m in msgs) - # Outer should NOT have a cycle error - assert not any("'Outer'" in m and "cycle" in m.lower() for m in msgs) - - def test_cycle_error_includes_cycle_path(self) -> None: - sys_ = System( - name="S", - provides=[_iref("I")], - connections=[_conn("A", "B"), _conn("B", "A")], - ) - arch = ArchFile(systems=[sys_]) - result = validate(arch) - msgs = _errors(result) - # The cycle path should show A -> B -> A or B -> A -> B - assert any("A" in m and "B" in m and "->" in m for m in msgs) - - def test_top_level_component_connections_checked(self) -> None: - comp = Component( - name="Root", - provides=[_iref("I")], - connections=[_conn("X", "Y"), _conn("Y", "Z"), _conn("Z", "X")], - ) - arch = ArchFile(components=[comp]) - _assert_error(arch, "Connection cycle") - - # ############### # Type Definition Cycles # ############### class TestTypeCycles: - """Check 3: Recursive type or interface definition cycles are errors.""" + """Check: Recursive type or interface definition cycles are errors.""" def test_no_types_no_error(self) -> None: _assert_clean(ArchFile()) @@ -316,7 +175,6 @@ def test_cycle_through_optional_type(self) -> None: def test_cycle_through_map_value(self) -> None: # type Registry { entries: Map } - # Note: key_type is NamedTypeRef pointing to a non-defined name (no cycle via key) arch = ArchFile( types=[ TypeDef( @@ -381,7 +239,7 @@ def test_two_independent_types_no_cycle(self) -> None: class TestInterfacePropagation: - """Check 4: Upstream interface declarations must be grounded in at least one member.""" + """Check: Upstream interface declarations must be grounded in at least one member.""" # ---- System propagation ---- @@ -555,14 +413,16 @@ def test_has_errors_true_when_errors_present(self) -> None: assert result.has_errors def test_multiple_check_failures_reported_together(self) -> None: - # A type cycle (error) AND a connection cycle (error) in one archfile. - sys_ = System( - name="Bad", - connections=[_conn("X", "Y"), _conn("Y", "X")], - ) + # Type cycle (error) + interface not propagated (error) in one archfile. arch = ArchFile( - systems=[sys_], types=[TypeDef(name="A", fields=[_nfield("self", "A")])], + systems=[ + System( + name="S", + provides=[_iref("I")], + components=[Component(name="C", requires=[_iref("Other")])], + ) + ], ) result = validate(arch) assert len(result.errors) >= 2 @@ -574,186 +434,141 @@ def test_empty_archfile_is_fully_valid(self) -> None: # ############### -# Unconnected Interfaces +# Unused Channels # ############### -class TestUnconnectedInterfaces: - """Check 5: Every requires/provides on a member must have a matching connect.""" +class TestUnusedChannels: + """Check: Channels declared in a scope must be bound by at least one member.""" - # ---- Leaf / empty cases — no warning ---- + # ---- No warning cases ---- def test_empty_archfile_no_warning(self) -> None: _assert_no_warning(ArchFile()) def test_leaf_component_no_warning(self) -> None: - # A top-level component with no sub-components is a leaf — not checked. - arch = ArchFile(components=[Component(name="C", requires=[_iref("I")], provides=[_iref("J")])]) + # A component with channels but no sub-components is a leaf — not checked. + arch = ArchFile( + components=[ + Component( + name="C", + channels=[_channel("ch", "IFace")], + requires=[_iref("IFace")], + ) + ] + ) _assert_no_warning(arch) def test_leaf_system_no_warning(self) -> None: - # A system with no members (components/systems/users) is a leaf — not checked. - arch = ArchFile(systems=[System(name="S", requires=[_iref("I")])]) + # A system with channels but no members is a leaf — not checked. + arch = ArchFile(systems=[System(name="S", channels=[_channel("ch", "I")])]) _assert_no_warning(arch) - def test_member_with_no_interfaces_no_warning(self) -> None: - # A member that declares no requires/provides needs no connect. - comp = Component(name="C") - sys_ = System(name="S", components=[comp]) - arch = ArchFile(systems=[sys_]) - _assert_no_warning(arch) - - # ---- Connected cases — no warning ---- - - def test_requires_connected_as_target_no_warning(self) -> None: - # C requires I: C must appear as the target of a connect (provider -> C). - comp = Component(name="C", requires=[_iref("I")]) - sys_ = System(name="S", components=[comp], connections=[_conn("Provider", "C", "I")]) + def test_channel_bound_by_requires_no_warning(self) -> None: + comp = Component(name="C", requires=[_iref("I", via="ch")]) + sys_ = System(name="S", channels=[_channel("ch", "I")], components=[comp]) arch = ArchFile(systems=[sys_]) _assert_no_warning(arch) - def test_provides_connected_as_source_no_warning(self) -> None: - # C provides I: C must appear as the source of a connect (C -> consumer). - comp = Component(name="C", provides=[_iref("I")]) - sys_ = System(name="S", components=[comp], connections=[_conn("C", "Consumer", "I")]) + def test_channel_bound_by_provides_no_warning(self) -> None: + comp = Component(name="C", provides=[_iref("I", via="ch")]) + sys_ = System(name="S", channels=[_channel("ch", "I")], components=[comp]) arch = ArchFile(systems=[sys_]) _assert_no_warning(arch) - def test_both_requires_and_provides_connected_no_warning(self) -> None: - # Provider (source) sends I to Consumer (target). - consumer = Component(name="Consumer", requires=[_iref("I")]) - provider = Component(name="Provider", provides=[_iref("I")]) - sys_ = System( - name="S", - components=[consumer, provider], - connections=[_conn("Provider", "Consumer", "I")], - ) + def test_channel_bound_by_user_no_warning(self) -> None: + user = UserDef(name="Customer", provides=[_iref("OrderRequest", via="order_in")]) + sys_ = System(name="S", channels=[_channel("order_in", "OrderRequest")], users=[user]) arch = ArchFile(systems=[sys_]) _assert_no_warning(arch) - def test_versioned_interface_matched_no_warning(self) -> None: - # C requires I@v2: C must be target of a connect using I@v2. - comp = Component(name="C", requires=[_iref("I", "v2")]) + def test_multiple_channels_all_bound_no_warning(self) -> None: + c1 = Component(name="C1", requires=[_iref("X", via="ch1")]) + c2 = Component(name="C2", provides=[_iref("Y", via="ch2")]) sys_ = System( name="S", - components=[comp], - connections=[_conn_v("Provider", "C", "I", "v2")], + channels=[_channel("ch1", "X"), _channel("ch2", "Y")], + components=[c1, c2], ) arch = ArchFile(systems=[sys_]) _assert_no_warning(arch) - def test_subcomponent_connected_no_warning(self) -> None: - # Connections inside a Component scope also checked. - # Sub provides I: Sub must be the source (Sub -> Consumer). - sub = Component(name="Sub", provides=[_iref("I")]) + def test_component_channel_bound_by_subcomponent_no_warning(self) -> None: + sub = Component(name="Sub", requires=[_iref("I", via="inner")]) outer = Component( name="Outer", + channels=[_channel("inner", "I")], components=[sub], - connections=[_conn("Sub", "Consumer", "I")], ) arch = ArchFile(components=[outer]) _assert_no_warning(arch) - def test_user_connected_as_target_no_warning(self) -> None: - # Alice requires Portal: Alice must appear as the target of a connect. - user = UserDef(name="Alice", requires=[_iref("Portal")]) - sys_ = System(name="S", users=[user], connections=[_conn("WebApp", "Alice", "Portal")]) - arch = ArchFile(systems=[sys_]) - _assert_no_warning(arch) - - # ---- Missing connect cases — warning ---- + # ---- Warning cases ---- - def test_requires_without_connect_warns(self) -> None: - comp = Component(name="C", requires=[_iref("I")]) - sys_ = System(name="S", components=[comp]) - arch = ArchFile(systems=[sys_]) - result = validate(arch) - msgs = _warnings(result) - assert any("'C'" in m and "requires" in m and "'I'" in m for m in msgs) - - def test_provides_without_connect_warns(self) -> None: - comp = Component(name="C", provides=[_iref("I")]) - sys_ = System(name="S", components=[comp]) + def test_unbound_channel_in_system_warns(self) -> None: + comp = Component(name="C", requires=[_iref("I")]) # no via binding + sys_ = System(name="S", channels=[_channel("ch", "I")], components=[comp]) arch = ArchFile(systems=[sys_]) result = validate(arch) msgs = _warnings(result) - assert any("'C'" in m and "provides" in m and "'I'" in m for m in msgs) + assert any("'ch'" in m and "'S'" in m for m in msgs) - def test_warning_message_mentions_container(self) -> None: - comp = Component(name="C", requires=[_iref("I")]) - sys_ = System(name="MySystem", components=[comp]) - arch = ArchFile(systems=[sys_]) - result = validate(arch) - msgs = _warnings(result) - assert any("'MySystem'" in m for m in msgs) - - def test_versioned_interface_mismatch_warns(self) -> None: - # C requires I@v2 but connect uses unversioned I — mismatch, no match. - comp = Component(name="C", requires=[_iref("I", "v2")]) - sys_ = System(name="S", components=[comp], connections=[_conn("C", "P", "I")]) - arch = ArchFile(systems=[sys_]) - result = validate(arch) - msgs = _warnings(result) - assert any("I@v2" in m for m in msgs) - - def test_unversioned_vs_versioned_connect_warns(self) -> None: - # C provides I (unversioned) but connect targets I@v1 — no match. - comp = Component(name="C", provides=[_iref("I")]) - sys_ = System(name="S", components=[comp], connections=[_conn_v("Src", "C", "I", "v1")]) - arch = ArchFile(systems=[sys_]) - result = validate(arch) - msgs = _warnings(result) - assert any("'C'" in m and "provides" in m and "'I'" in m for m in msgs) - - def test_user_requires_without_connect_warns(self) -> None: - user = UserDef(name="Alice", requires=[_iref("Portal")]) - sys_ = System(name="S", users=[user]) - arch = ArchFile(systems=[sys_]) - result = validate(arch) - msgs = _warnings(result) - assert any("'Alice'" in m and "requires" in m and "'Portal'" in m for m in msgs) - - def test_user_provides_without_connect_warns(self) -> None: - user = UserDef(name="Operator", provides=[_iref("Report")]) - sys_ = System(name="S", users=[user]) + def test_warning_message_mentions_channel_and_scope(self) -> None: + comp = Component(name="C") + sys_ = System(name="MySystem", channels=[_channel("payment", "PaymentRequest")], components=[comp]) arch = ArchFile(systems=[sys_]) result = validate(arch) msgs = _warnings(result) - assert any("'Operator'" in m and "provides" in m and "'Report'" in m for m in msgs) + assert any("'payment'" in m and "'MySystem'" in m for m in msgs) - def test_multiple_missing_interfaces_warn_each(self) -> None: - c1 = Component(name="C1", requires=[_iref("X")]) - c2 = Component(name="C2", provides=[_iref("Y")]) - sys_ = System(name="S", components=[c1, c2]) + def test_partially_bound_channel_second_unbound_warns(self) -> None: + # ch1 is bound, ch2 is not. + c1 = Component(name="C1", requires=[_iref("X", via="ch1")]) + sys_ = System( + name="S", + channels=[_channel("ch1", "X"), _channel("ch2", "Y")], + components=[c1], + ) arch = ArchFile(systems=[sys_]) result = validate(arch) msgs = _warnings(result) - assert any("'C1'" in m and "'X'" in m for m in msgs) - assert any("'C2'" in m and "'Y'" in m for m in msgs) + assert any("'ch2'" in m for m in msgs) + assert not any("'ch1'" in m for m in msgs) - def test_subcomponent_requires_without_connect_warns(self) -> None: - sub = Component(name="Sub", requires=[_iref("I")]) - outer = Component(name="Outer", components=[sub]) + def test_unbound_channel_in_component_scope_warns(self) -> None: + sub = Component(name="Sub", requires=[_iref("I")]) # no via + outer = Component( + name="Outer", + channels=[_channel("inner", "I")], + components=[sub], + ) arch = ArchFile(components=[outer]) result = validate(arch) msgs = _warnings(result) - assert any("'Sub'" in m and "requires" in m and "'I'" in m for m in msgs) + assert any("'inner'" in m and "'Outer'" in m for m in msgs) - def test_nested_system_members_checked_recursively(self) -> None: - # The inner system has a member with an unconnected interface. - inner_comp = Component(name="IC", provides=[_iref("Inner")]) - inner = System(name="Inner", components=[inner_comp]) + def test_nested_system_channels_checked_recursively(self) -> None: + inner_comp = Component(name="IC") # no via binding + inner = System( + name="Inner", + channels=[_channel("ch", "I")], + components=[inner_comp], + ) outer = System(name="Outer", systems=[inner]) arch = ArchFile(systems=[outer]) result = validate(arch) msgs = _warnings(result) - assert any("'IC'" in m and "'Inner'" in m for m in msgs) + assert any("'ch'" in m and "'Inner'" in m for m in msgs) def test_qualified_name_used_in_warning_when_set(self) -> None: - comp = Component(name="C", qualified_name="Root::S::C", requires=[_iref("I")]) - sys_ = System(name="S", qualified_name="Root::S", components=[comp]) + comp = Component(name="C") + sys_ = System( + name="S", + qualified_name="Root::S", + channels=[_channel("ch", "I")], + components=[comp], + ) arch = ArchFile(systems=[sys_]) result = validate(arch) msgs = _warnings(result) - assert any("'Root::S::C'" in m for m in msgs) assert any("'Root::S'" in m for m in msgs) diff --git a/tests/views/backend/test_diagram.py b/tests/views/backend/test_diagram.py index 717e7e7..d653242 100644 --- a/tests/views/backend/test_diagram.py +++ b/tests/views/backend/test_diagram.py @@ -8,7 +8,7 @@ import pytest -from archml.model.entities import Component, Connection, ConnectionEndpoint, InterfaceRef, System +from archml.model.entities import ChannelDef, Component, InterfaceRef, System from archml.views.backend.diagram import render_diagram from archml.views.placement import compute_layout from archml.views.topology import build_viz_diagram @@ -20,16 +20,8 @@ _SVG_NS = "http://www.w3.org/2000/svg" -def _iref(name: str, version: str | None = None) -> InterfaceRef: - return InterfaceRef(name=name, version=version) - - -def _conn(source: str, target: str, interface: str) -> Connection: - return Connection( - source=ConnectionEndpoint(entity=source), - target=ConnectionEndpoint(entity=target), - interface=InterfaceRef(name=interface), - ) +def _iref(name: str, version: str | None = None, via: str | None = None) -> InterfaceRef: + return InterfaceRef(name=name, version=version, via=via) def _render_and_parse(entity: Component | System, tmp_path: Path, **kwargs: object) -> ET.Element: @@ -237,13 +229,13 @@ def test_render_versioned_terminal_label(tmp_path: Path) -> None: def test_render_edge_label_present(tmp_path: Path) -> None: - """Connection interface name appears as a text label in the SVG.""" - a = Component(name="A", requires=[_iref("PaymentRequest")]) - b = Component(name="B", provides=[_iref("PaymentRequest")]) + """Channel interface name appears as a text label in the SVG.""" + a = Component(name="A", requires=[_iref("PaymentRequest", via="payment")]) + b = Component(name="B", provides=[_iref("PaymentRequest", via="payment")]) sys = System( name="Root", + channels=[ChannelDef(name="payment", interface=InterfaceRef(name="PaymentRequest"))], components=[a, b], - connections=[_conn("A", "B", "PaymentRequest")], ) root = _render_and_parse(sys, tmp_path) assert "PaymentRequest" in _text_content(root) @@ -269,12 +261,12 @@ def test_render_clip_paths_defined_in_defs(tmp_path: Path) -> None: def test_render_edge_polyline_present(tmp_path: Path) -> None: """An edge between two children produces at least one ```` element.""" - a = Component(name="A", requires=[_iref("IFace")]) - b = Component(name="B", provides=[_iref("IFace")]) + a = Component(name="A", requires=[_iref("IFace", via="ch")]) + b = Component(name="B", provides=[_iref("IFace", via="ch")]) sys = System( name="Root", + channels=[ChannelDef(name="ch", interface=InterfaceRef(name="IFace"))], components=[a, b], - connections=[_conn("A", "B", "IFace")], ) root = _render_and_parse(sys, tmp_path) polylines = list(root.iter(f"{{{_SVG_NS}}}polyline")) @@ -283,12 +275,12 @@ def test_render_edge_polyline_present(tmp_path: Path) -> None: def test_render_edge_has_explicit_arrowhead_polygon(tmp_path: Path) -> None: """An edge produces an explicit filled ```` arrowhead in the SVG.""" - a = Component(name="A", requires=[_iref("IFace")]) - b = Component(name="B", provides=[_iref("IFace")]) + a = Component(name="A", requires=[_iref("IFace", via="ch")]) + b = Component(name="B", provides=[_iref("IFace", via="ch")]) sys = System( name="Root", + channels=[ChannelDef(name="ch", interface=InterfaceRef(name="IFace"))], components=[a, b], - connections=[_conn("A", "B", "IFace")], ) root = _render_and_parse(sys, tmp_path) polygons = list(root.iter(f"{{{_SVG_NS}}}polygon")) @@ -304,17 +296,15 @@ def test_render_edge_has_explicit_arrowhead_polygon(tmp_path: Path) -> None: def test_render_ecommerce_system(tmp_path: Path) -> None: - """Full integration: multi-component system with connections renders without error.""" + """Full integration: multi-component system with channel renders without error.""" sys = System( name="ECommerce", + channels=[ChannelDef(name="payment", interface=InterfaceRef(name="PaymentRequest"))], components=[ - Component(name="OrderService", requires=[_iref("PaymentRequest")]), - Component(name="PaymentService", provides=[_iref("PaymentRequest")]), + Component(name="OrderService", requires=[_iref("PaymentRequest", via="payment")]), + Component(name="PaymentService", provides=[_iref("PaymentRequest", via="payment")]), Component(name="NotificationService", requires=[_iref("OrderRequest")]), ], - connections=[ - _conn("OrderService", "PaymentService", "PaymentRequest"), - ], ) root = _render_and_parse(sys, tmp_path) texts = _text_content(root) diff --git a/tests/views/test_placement.py b/tests/views/test_placement.py index a3eecd8..5892d3c 100644 --- a/tests/views/test_placement.py +++ b/tests/views/test_placement.py @@ -5,7 +5,7 @@ import pytest -from archml.model.entities import Component, Connection, ConnectionEndpoint, InterfaceRef, System +from archml.model.entities import ChannelDef, Component, InterfaceRef, System from archml.views.placement import ( LayoutConfig, LayoutPlan, @@ -25,16 +25,8 @@ # ############### -def _iref(name: str, version: str | None = None) -> InterfaceRef: - return InterfaceRef(name=name, version=version) - - -def _conn(source: str, target: str, interface: str) -> Connection: - return Connection( - source=ConnectionEndpoint(entity=source), - target=ConnectionEndpoint(entity=target), - interface=InterfaceRef(name=interface), - ) +def _iref(name: str, version: str | None = None, via: str | None = None) -> InterfaceRef: + return InterfaceRef(name=name, version=version, via=via) def _port(node_id: str, direction: str, name: str) -> VizPort: @@ -626,28 +618,28 @@ def test_peripheral_nodes_outside_boundary_in_total_width() -> None: def test_ecommerce_system_produces_complete_plan() -> None: - """Full integration: ecommerce system with multiple components and connections.""" + """Full integration: ecommerce system with multiple components connected via channels.""" sys = System( name="ECommerce", + channels=[ + ChannelDef(name="payment", interface=InterfaceRef(name="PaymentRequest")), + ChannelDef(name="notification", interface=InterfaceRef(name="OrderRequest")), + ], components=[ Component( name="OrderService", - requires=[_iref("PaymentRequest")], - provides=[_iref("OrderRequest")], + requires=[_iref("PaymentRequest", via="payment")], + provides=[_iref("OrderRequest", via="notification")], ), Component( name="PaymentService", - provides=[_iref("PaymentRequest")], + provides=[_iref("PaymentRequest", via="payment")], ), Component( name="NotificationService", - requires=[_iref("OrderRequest")], + requires=[_iref("OrderRequest", via="notification")], ), ], - connections=[ - _conn("OrderService", "PaymentService", "PaymentRequest"), - _conn("NotificationService", "OrderService", "OrderRequest"), - ], ) diagram = build_viz_diagram(sys) plan = compute_layout(diagram) @@ -667,17 +659,17 @@ def test_ecommerce_order_service_left_of_payment_service() -> None: """OrderService (requirer) is to the left of PaymentService (provider).""" sys = System( name="ECommerce", + channels=[ChannelDef(name="payment", interface=InterfaceRef(name="PaymentRequest"))], components=[ Component( name="OrderService", - requires=[_iref("PaymentRequest")], + requires=[_iref("PaymentRequest", via="payment")], ), Component( name="PaymentService", - provides=[_iref("PaymentRequest")], + provides=[_iref("PaymentRequest", via="payment")], ), ], - connections=[_conn("OrderService", "PaymentService", "PaymentRequest")], ) diagram = build_viz_diagram(sys) plan = compute_layout(diagram) @@ -686,37 +678,39 @@ def test_ecommerce_order_service_left_of_payment_service() -> None: assert plan.nodes[order_id].x < plan.nodes[payment_id].x -def test_external_actor_resolved_and_positioned() -> None: - """An external actor resolved via external_entities receives a layout entry.""" - stripe = Component(name="Stripe", is_external=True, provides=[_iref("PaymentGateway")]) +def test_external_actor_in_components_positioned() -> None: + """An external actor declared in components receives a layout entry.""" + stripe = Component(name="Stripe", is_external=True, provides=[_iref("PaymentGateway", via="payment")]) sys = System( name="ECommerce", + channels=[ChannelDef(name="payment", interface=InterfaceRef(name="PaymentGateway"))], components=[ - Component(name="OrderService", requires=[_iref("PaymentGateway")]), + Component(name="OrderService", requires=[_iref("PaymentGateway", via="payment")]), + stripe, ], - connections=[_conn("OrderService", "Stripe", "PaymentGateway")], ) - diagram = build_viz_diagram(sys, external_entities={"Stripe": stripe}) + diagram = build_viz_diagram(sys) plan = compute_layout(diagram) - stripe_node = next(n for n in diagram.peripheral_nodes if n.label == "Stripe") + stripe_node = next(c for c in diagram.root.children if c.label == "Stripe") assert stripe_node.id in plan.nodes -def test_external_actor_right_of_boundary_when_it_provides() -> None: - """An external provider (target of edges) is placed right of the boundary.""" - stripe = Component(name="Stripe", is_external=True, provides=[_iref("PaymentGateway")]) +def test_external_actor_right_of_requirer_when_it_provides() -> None: + """An external provider (target of edge) is placed right of the requirer.""" + stripe = Component(name="Stripe", is_external=True, provides=[_iref("PaymentGateway", via="payment")]) sys = System( name="ECommerce", + channels=[ChannelDef(name="payment", interface=InterfaceRef(name="PaymentGateway"))], components=[ - Component(name="OrderService", requires=[_iref("PaymentGateway")]), + Component(name="OrderService", requires=[_iref("PaymentGateway", via="payment")]), + stripe, ], - connections=[_conn("OrderService", "Stripe", "PaymentGateway")], ) - diagram = build_viz_diagram(sys, external_entities={"Stripe": stripe}) + diagram = build_viz_diagram(sys) plan = compute_layout(diagram) - bl = plan.boundaries[diagram.root.id] - stripe_node = next(n for n in diagram.peripheral_nodes if n.label == "Stripe") - assert plan.nodes[stripe_node.id].x >= bl.x + bl.width + order_id = next(c.id for c in diagram.root.children if c.label == "OrderService") + stripe_id = next(c.id for c in diagram.root.children if c.label == "Stripe") + assert plan.nodes[order_id].x < plan.nodes[stripe_id].x def test_all_ports_in_diagram_have_anchors() -> None: @@ -725,11 +719,11 @@ def test_all_ports_in_diagram_have_anchors() -> None: name="ECommerce", requires=[_iref("ClientRequest")], provides=[_iref("ClientResponse")], + channels=[ChannelDef(name="b_service", interface=InterfaceRef(name="BService"))], components=[ - Component(name="A", requires=[_iref("BService")]), - Component(name="B", provides=[_iref("BService")]), + Component(name="A", requires=[_iref("BService", via="b_service")]), + Component(name="B", provides=[_iref("BService", via="b_service")]), ], - connections=[_conn("A", "B", "BService")], ) diagram = build_viz_diagram(sys) plan = compute_layout(diagram) diff --git a/tests/views/test_topology.py b/tests/views/test_topology.py index 9c70582..1171743 100644 --- a/tests/views/test_topology.py +++ b/tests/views/test_topology.py @@ -3,7 +3,7 @@ """Tests for the abstract visualization topology model and its builder.""" -from archml.model.entities import Component, Connection, ConnectionEndpoint, InterfaceRef, System, UserDef +from archml.model.entities import ChannelDef, Component, InterfaceRef, System, UserDef from archml.views.topology import ( VizBoundary, VizNode, @@ -17,22 +17,20 @@ # ############### -def _iref(name: str, version: str | None = None) -> InterfaceRef: - return InterfaceRef(name=name, version=version) +def _iref(name: str, version: str | None = None, via: str | None = None) -> InterfaceRef: + return InterfaceRef(name=name, version=version, via=via) -def _conn( - source: str, - target: str, +def _channel( + name: str, interface: str, version: str | None = None, protocol: str | None = None, is_async: bool = False, description: str | None = None, -) -> Connection: - return Connection( - source=ConnectionEndpoint(entity=source), - target=ConnectionEndpoint(entity=target), +) -> ChannelDef: + return ChannelDef( + name=name, interface=InterfaceRef(name=interface, version=version), protocol=protocol, is_async=is_async, @@ -334,107 +332,48 @@ def test_no_terminals_for_leaf_without_interfaces() -> None: # ############### -# Peripheral nodes — external actors +# Edges — channel-based # ############### -def test_external_actor_stub_created_for_unresolved_endpoint() -> None: - """An endpoint not among the children creates a stub external node.""" - child = Component(name="A", requires=[_iref("IFace")]) - conn = _conn("A", "StripeAPI", "IFace") - parent = System(name="S", components=[child], connections=[conn]) +def test_edge_created_for_channel_binding() -> None: + """A VizEdge is created for each channel with a provider-requirer pair.""" + a = Component(name="A", requires=[_iref("IFace", via="ch")]) + b = Component(name="B", provides=[_iref("IFace", via="ch")]) + parent = System(name="S", channels=[_channel("ch", "IFace")], components=[a, b]) diag = build_viz_diagram(parent) - ext_ids = _node_ids(diag.peripheral_nodes) - assert "ext.StripeAPI" in ext_ids - - -def test_external_actor_stub_has_external_kind() -> None: - child = Component(name="A", requires=[_iref("IFace")]) - conn = _conn("A", "Ext", "IFace") - parent = System(name="S", components=[child], connections=[conn]) - diag = build_viz_diagram(parent) - ext_node = next(n for n in diag.peripheral_nodes if n.id == "ext.Ext") - assert ext_node.kind in ("external_component", "external_system") - - -def test_external_actor_resolved_from_external_entities() -> None: - """When external_entities provides model data it is used for the node.""" - child = Component(name="A", requires=[_iref("Pay")]) - stripe = Component(name="StripeAPI", title="Stripe", provides=[_iref("Pay")], is_external=True) - conn = _conn("A", "StripeAPI", "Pay") - parent = System(name="S", components=[child], connections=[conn]) - - diag = build_viz_diagram(parent, external_entities={"StripeAPI": stripe}) - - ext_node = next(n for n in diag.peripheral_nodes if n.label == "StripeAPI") - assert ext_node.title == "Stripe" - assert ext_node.kind == "external_component" - # Resolved node carries full ports. - assert any(p.interface_name == "Pay" for p in ext_node.ports) - - -def test_external_actor_appears_only_once_even_in_multiple_connections() -> None: - """The same external actor referenced twice produces a single peripheral node.""" - a = Component(name="A", requires=[_iref("X"), _iref("Y")]) - conn1 = _conn("A", "Ext", "X") - conn2 = _conn("A", "Ext", "Y") - parent = System(name="S", components=[a], connections=[conn1, conn2]) - diag = build_viz_diagram(parent) - ext_nodes = [n for n in diag.peripheral_nodes if n.label == "Ext"] - assert len(ext_nodes) == 1 - - -def test_child_not_added_to_peripheral_nodes() -> None: - """A direct child of the focus entity never appears in peripheral_nodes.""" - child_a = Component(name="A", requires=[_iref("IFace")]) - child_b = Component(name="B", provides=[_iref("IFace")]) - conn = _conn("A", "B", "IFace") - parent = System(name="S", components=[child_a, child_b], connections=[conn]) - diag = build_viz_diagram(parent) - peripheral_labels = {n.label for n in diag.peripheral_nodes} - assert "A" not in peripheral_labels - assert "B" not in peripheral_labels - - -# ############### -# Edges -# ############### + assert len(diag.edges) == 1 -def test_edge_created_for_connection() -> None: - """A VizEdge is created for each connect statement.""" - a = Component(name="A", requires=[_iref("IFace")]) - b = Component(name="B", provides=[_iref("IFace")]) - conn = _conn("A", "B", "IFace") - parent = System(name="S", components=[a, b], connections=[conn]) +def test_channel_with_no_binders_creates_no_edges() -> None: + """A channel that no sub-entity binds to produces no edges.""" + a = Component(name="A", requires=[_iref("IFace")]) # no via + parent = System(name="S", channels=[_channel("ch", "IFace")], components=[a]) diag = build_viz_diagram(parent) - assert len(diag.edges) == 1 + assert len(diag.edges) == 0 -def test_edge_label_is_interface_name() -> None: - a = Component(name="A", requires=[_iref("PayReq")]) - b = Component(name="B", provides=[_iref("PayReq")]) - conn = _conn("A", "B", "PayReq") - parent = System(name="S", components=[a, b], connections=[conn]) +def test_edge_label_is_channel_interface_name() -> None: + a = Component(name="A", requires=[_iref("PayReq", via="pay")]) + b = Component(name="B", provides=[_iref("PayReq", via="pay")]) + parent = System(name="S", channels=[_channel("pay", "PayReq")], components=[a, b]) diag = build_viz_diagram(parent) assert diag.edges[0].label == "PayReq" def test_edge_label_includes_version() -> None: - a = Component(name="A", requires=[_iref("API", "v2")]) - b = Component(name="B", provides=[_iref("API", "v2")]) - conn = _conn("A", "B", "API", version="v2") - parent = System(name="S", components=[a, b], connections=[conn]) + a = Component(name="A", requires=[_iref("API", "v2", via="ch")]) + b = Component(name="B", provides=[_iref("API", "v2", via="ch")]) + parent = System(name="S", channels=[_channel("ch", "API", version="v2")], components=[a, b]) diag = build_viz_diagram(parent) assert diag.edges[0].label == "API@v2" def test_edge_source_port_is_requires_port() -> None: - """Edge source_port_id references a requires port on the source node.""" - a = Component(name="A", requires=[_iref("IFace")]) - b = Component(name="B", provides=[_iref("IFace")]) - conn = _conn("A", "B", "IFace") - parent = System(name="S", components=[a, b], connections=[conn]) + """Edge source_port_id references a requires port on the requirer node.""" + a = Component(name="A", requires=[_iref("IFace", via="ch")]) + b = Component(name="B", provides=[_iref("IFace", via="ch")]) + parent = System(name="S", channels=[_channel("ch", "IFace")], components=[a, b]) diag = build_viz_diagram(parent) all_ports = collect_all_ports(diag) src_port = all_ports[diag.edges[0].source_port_id] @@ -443,11 +382,10 @@ def test_edge_source_port_is_requires_port() -> None: def test_edge_target_port_is_provides_port() -> None: - """Edge target_port_id references a provides port on the target node.""" - a = Component(name="A", requires=[_iref("IFace")]) - b = Component(name="B", provides=[_iref("IFace")]) - conn = _conn("A", "B", "IFace") - parent = System(name="S", components=[a, b], connections=[conn]) + """Edge target_port_id references a provides port on the provider node.""" + a = Component(name="A", requires=[_iref("IFace", via="ch")]) + b = Component(name="B", provides=[_iref("IFace", via="ch")]) + parent = System(name="S", channels=[_channel("ch", "IFace")], components=[a, b]) diag = build_viz_diagram(parent) all_ports = collect_all_ports(diag) tgt_port = all_ports[diag.edges[0].target_port_id] @@ -456,11 +394,10 @@ def test_edge_target_port_is_provides_port() -> None: def test_edge_source_and_target_port_owners() -> None: - """Source port belongs to the source node; target port to the target node.""" - a = Component(name="A", requires=[_iref("IFace")]) - b = Component(name="B", provides=[_iref("IFace")]) - conn = _conn("A", "B", "IFace") - parent = System(name="S", components=[a, b], connections=[conn]) + """Source port belongs to the requirer node; target port to the provider node.""" + a = Component(name="A", requires=[_iref("IFace", via="ch")]) + b = Component(name="B", provides=[_iref("IFace", via="ch")]) + parent = System(name="S", channels=[_channel("ch", "IFace")], components=[a, b]) diag = build_viz_diagram(parent) all_ports = collect_all_ports(diag) edge = diag.edges[0] @@ -469,10 +406,14 @@ def test_edge_source_and_target_port_owners() -> None: def test_edge_protocol_and_async_propagated() -> None: - a = Component(name="A", requires=[_iref("X")]) - b = Component(name="B", provides=[_iref("X")]) - conn = _conn("A", "B", "X", protocol="gRPC", is_async=True, description="async call") - parent = System(name="S", components=[a, b], connections=[conn]) + """Channel protocol and async attributes are propagated to the edge.""" + a = Component(name="A", requires=[_iref("X", via="ch")]) + b = Component(name="B", provides=[_iref("X", via="ch")]) + parent = System( + name="S", + channels=[_channel("ch", "X", protocol="gRPC", is_async=True, description="async call")], + components=[a, b], + ) diag = build_viz_diagram(parent) edge = diag.edges[0] assert edge.protocol == "gRPC" @@ -480,73 +421,67 @@ def test_edge_protocol_and_async_propagated() -> None: assert edge.description == "async call" -def test_multiple_edges_created() -> None: - a = Component(name="A", requires=[_iref("X"), _iref("Y")]) - b = Component(name="B", provides=[_iref("X")]) - c = Component(name="C", provides=[_iref("Y")]) - conns = [_conn("A", "B", "X"), _conn("A", "C", "Y")] - parent = System(name="S", components=[a, b, c], connections=conns) +def test_multiple_edges_from_multiple_channels() -> None: + """Multiple channels each produce their own edges.""" + a = Component( + name="A", + requires=[_iref("X", via="ch1"), _iref("Y", via="ch2")], + ) + b = Component(name="B", provides=[_iref("X", via="ch1")]) + c = Component(name="C", provides=[_iref("Y", via="ch2")]) + parent = System( + name="S", + channels=[_channel("ch1", "X"), _channel("ch2", "Y")], + components=[a, b, c], + ) diag = build_viz_diagram(parent) assert len(diag.edges) == 2 -def test_edge_to_unknown_source_creates_stub_and_edge() -> None: - """An unknown source endpoint becomes a stub external node and the edge is kept.""" - b = Component(name="B", provides=[_iref("IFace")]) - conn = _conn("Ghost", "B", "IFace") - parent = System(name="S", components=[b], connections=[conn]) +def test_external_component_binds_to_channel() -> None: + """An external component (is_external=True) can bind to a channel.""" + stripe = Component(name="StripeAPI", is_external=True, requires=[_iref("PaymentRequest", via="payment")]) + gateway = Component(name="PaymentGateway", provides=[_iref("PaymentRequest", via="payment")]) + parent = System( + name="S", + channels=[_channel("payment", "PaymentRequest")], + components=[stripe, gateway], + ) diag = build_viz_diagram(parent) - # Stub node is created for "Ghost". - peripheral_labels = {n.label for n in diag.peripheral_nodes} - assert "Ghost" in peripheral_labels - # Edge is still produced (connecting stub → B). + # Both should be child nodes inside the boundary. + child_labels = {c.label for c in diag.root.children} + assert "StripeAPI" in child_labels + assert "PaymentGateway" in child_labels + # One edge connects them through the channel. assert len(diag.edges) == 1 + stripe_node = next(c for c in diag.root.children if c.label == "StripeAPI") + assert isinstance(stripe_node, VizNode) + assert stripe_node.kind == "external_component" -def test_edge_to_unknown_target_creates_stub_and_edge() -> None: - """An unknown target endpoint becomes a stub external node and the edge is kept.""" - a = Component(name="A", requires=[_iref("IFace")]) - conn = _conn("A", "Ghost", "IFace") - parent = System(name="S", components=[a], connections=[conn]) +def test_child_component_not_in_peripheral_nodes() -> None: + """Direct children of the focus entity never appear in peripheral_nodes.""" + a = Component(name="A", requires=[_iref("IFace", via="ch")]) + b = Component(name="B", provides=[_iref("IFace", via="ch")]) + parent = System(name="S", channels=[_channel("ch", "IFace")], components=[a, b]) diag = build_viz_diagram(parent) peripheral_labels = {n.label for n in diag.peripheral_nodes} - assert "Ghost" in peripheral_labels - assert len(diag.edges) == 1 - - -# ############### -# Implicit ports -# ############### - - -def test_implicit_port_created_when_requires_missing() -> None: - """When the source lacks an explicit requires declaration an implicit port is added.""" - # A has no requires declarations at all. - a = Component(name="A") - b = Component(name="B", provides=[_iref("IFace")]) - conn = _conn("A", "B", "IFace") - parent = System(name="S", components=[a, b], connections=[conn]) - diag = build_viz_diagram(parent) - - assert len(diag.edges) == 1 - all_ports = collect_all_ports(diag) - src_port = all_ports[diag.edges[0].source_port_id] - assert src_port.direction == "requires" - assert src_port.interface_name == "IFace" + assert "A" not in peripheral_labels + assert "B" not in peripheral_labels -def test_implicit_port_created_when_provides_missing() -> None: - a = Component(name="A", requires=[_iref("IFace")]) - b = Component(name="B") # no provides - conn = _conn("A", "B", "IFace") - parent = System(name="S", components=[a, b], connections=[conn]) +def test_many_requirers_one_provider_creates_n_edges() -> None: + """N requirers × 1 provider on the same channel creates N edges.""" + a = Component(name="A", requires=[_iref("X", via="ch")]) + b = Component(name="B", requires=[_iref("X", via="ch")]) + c = Component(name="C", provides=[_iref("X", via="ch")]) + parent = System( + name="S", + channels=[_channel("ch", "X")], + components=[a, b, c], + ) diag = build_viz_diagram(parent) - - assert len(diag.edges) == 1 - all_ports = collect_all_ports(diag) - tgt_port = all_ports[diag.edges[0].target_port_id] - assert tgt_port.direction == "provides" - assert tgt_port.interface_name == "IFace" + assert len(diag.edges) == 2 # ############### @@ -580,23 +515,11 @@ def test_collect_all_ports_includes_terminal_ports() -> None: assert "Out" in names -def test_collect_all_ports_includes_external_node_ports() -> None: - child = Component(name="A", requires=[_iref("Pay")]) - stripe = Component(name="Stripe", provides=[_iref("Pay")], is_external=True) - conn = _conn("A", "Stripe", "Pay") - parent = System(name="S", components=[child], connections=[conn]) - diag = build_viz_diagram(parent, external_entities={"Stripe": stripe}) - all_ports = collect_all_ports(diag) - prov_ports = [p for p in all_ports.values() if p.direction == "provides" and p.interface_name == "Pay"] - assert len(prov_ports) >= 1 - - def test_collect_all_ports_returns_unique_ids() -> None: """All returned port IDs are distinct.""" - a = Component(name="A", requires=[_iref("X")]) - b = Component(name="B", provides=[_iref("X")]) - conn = _conn("A", "B", "X") - parent = System(name="S", components=[a, b], connections=[conn]) + a = Component(name="A", requires=[_iref("X", via="ch")]) + b = Component(name="B", provides=[_iref("X", via="ch")]) + parent = System(name="S", channels=[_channel("ch", "X")], components=[a, b]) diag = build_viz_diagram(parent) all_ports = collect_all_ports(diag) assert len(all_ports) == len(set(all_ports)) @@ -612,64 +535,67 @@ def test_ecommerce_system_topology() -> None: order_svc = Component( name="OrderService", title="Order Service", - requires=[_iref("PaymentRequest"), _iref("InventoryCheck")], + requires=[ + _iref("PaymentRequest", via="payment"), + _iref("InventoryCheck", via="inventory"), + ], provides=[_iref("OrderConfirmation")], ) payment_gw = Component( name="PaymentGateway", title="Payment Gateway", tags=["critical", "pci-scope"], - requires=[_iref("PaymentRequest")], - provides=[_iref("PaymentResult")], - ) - inventory = Component( - name="InventoryManager", - title="Inventory Manager", - requires=[_iref("InventoryCheck")], - provides=[_iref("InventoryStatus")], + provides=[_iref("PaymentRequest", via="payment")], + requires=[_iref("StripePayment", via="stripe")], ) stripe = Component( name="StripeAPI", title="Stripe Payment API", - requires=[_iref("PaymentRequest")], - provides=[_iref("PaymentResult")], is_external=True, + provides=[_iref("StripePayment", via="stripe")], + ) + inventory = Component( + name="InventoryManager", + title="Inventory Manager", + provides=[_iref("InventoryCheck", via="inventory")], ) ecommerce = System( name="ECommerce", title="E-Commerce Platform", - components=[order_svc, payment_gw, inventory], - connections=[ - _conn("OrderService", "PaymentGateway", "PaymentRequest"), - _conn("OrderService", "InventoryManager", "InventoryCheck"), - _conn("PaymentGateway", "StripeAPI", "PaymentRequest", protocol="HTTP", is_async=True), + channels=[ + _channel("payment", "PaymentRequest"), + _channel("inventory", "InventoryCheck"), + _channel("stripe", "StripePayment", protocol="HTTP", is_async=True), ], + components=[order_svc, payment_gw, inventory, stripe], ) - diag = build_viz_diagram(ecommerce, external_entities={"StripeAPI": stripe}) + diag = build_viz_diagram(ecommerce) # Root boundary is ECommerce. assert diag.root.id == "ECommerce" assert diag.root.kind == "system" - # Three children inside the boundary. + # Four children inside the boundary (including external StripeAPI). child_labels = {c.label for c in diag.root.children if isinstance(c, VizNode)} - assert child_labels == {"OrderService", "PaymentGateway", "InventoryManager"} - - # StripeAPI is a peripheral (external) node. - peripheral_labels = {n.label for n in diag.peripheral_nodes} - assert "StripeAPI" in peripheral_labels - stripe_node = next(n for n in diag.peripheral_nodes if n.label == "StripeAPI") + assert "OrderService" in child_labels + assert "PaymentGateway" in child_labels + assert "InventoryManager" in child_labels + assert "StripeAPI" in child_labels + + # StripeAPI is an external_component child (not peripheral). + stripe_node = next(c for c in diag.root.children if c.label == "StripeAPI") + assert isinstance(stripe_node, VizNode) assert stripe_node.kind == "external_component" - # Three edges. + # Three edges (one per channel that has both a provider and requirer). assert len(diag.edges) == 3 edge_labels = {e.label for e in diag.edges} assert "PaymentRequest" in edge_labels assert "InventoryCheck" in edge_labels - # Async annotation on the Stripe edge. + # Async annotation on the stripe edge. stripe_edge = next(e for e in diag.edges if e.protocol == "HTTP") assert stripe_edge.is_async is True @@ -727,35 +653,23 @@ def test_user_child_ports_are_built() -> None: assert "provides" in directions -def test_user_as_connection_endpoint() -> None: - """A user can be a source or target of a connection edge.""" - iref = InterfaceRef(name="OrderRequest") - customer = UserDef(name="Customer", provides=[iref]) - order_svc = Component(name="OrderService", requires=[iref]) - conn = _conn("Customer", "OrderService", "OrderRequest") +def test_user_channel_binding_creates_edge() -> None: + """A user binding to a channel via 'via' creates an edge.""" + customer = UserDef(name="Customer", provides=[InterfaceRef(name="OrderRequest", via="order_in")]) + order_svc = Component(name="OrderService", requires=[InterfaceRef(name="OrderRequest", via="order_in")]) system = System( name="ECommerce", + channels=[ChannelDef(name="order_in", interface=InterfaceRef(name="OrderRequest"))], users=[customer], components=[order_svc], - connections=[conn], ) diag = build_viz_diagram(system) assert len(diag.edges) == 1 edge = diag.edges[0] - assert "Customer" in edge.source_port_id - assert "OrderService" in edge.target_port_id + # OrderService is the requirer → source port + # Customer is the provider → target port + assert "OrderService" in edge.source_port_id + assert "Customer" in edge.target_port_id all_ports = collect_all_ports(diag) assert edge.source_port_id in all_ports assert edge.target_port_id in all_ports - - -def test_external_user_as_peripheral_node() -> None: - """A user supplied via external_entities appears as an external_user peripheral node.""" - iref = InterfaceRef(name="Report") - ext_user = UserDef(name="Analyst", provides=[iref]) - comp = Component(name="ReportService", requires=[iref]) - conn = _conn("Analyst", "ReportService", "Report") - system = System(name="S", components=[comp], connections=[conn]) - diag = build_viz_diagram(system, external_entities={"Analyst": ext_user}) - kinds = {n.kind for n in diag.peripheral_nodes} - assert "external_user" in kinds From b7cf95ecaed6101ad7680467c5d6c46bc4b7abc9 Mon Sep 17 00:00:00 2001 From: Andi Hellmund Date: Sat, 14 Mar 2026 10:01:31 +0100 Subject: [PATCH 2/3] finalize language syntax --- README.md | 107 +++++++------- docs/LANGUAGE_SYNTAX.md | 316 +++++++++++++++++++++++++++------------- 2 files changed, 260 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index 9705ad4..c0af7ca 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ArchML sits between these extremes: - **Text-first** — `.archml` files are plain text, stored in git, reviewed in pull requests. - **Model-based** — one model, many views. Define a component once; reference it everywhere. -- **Consistency checking** — the tooling catches dangling references, unused interfaces, and disconnected components. +- **Consistency checking** — the tooling catches dangling references, ports missing `connect` or `expose`, and type mismatches across channels. - **Navigable views** — drill down from system landscape to individual component internals. - **Sphinx-native** — embed live architecture views directly in your documentation. @@ -65,100 +65,91 @@ interface InventoryCheck { // systems/ecommerce.archml from types import OrderRequest, OrderConfirmation, PaymentRequest, InventoryCheck -user Customer { - title = "Customer" - description = "An end user who places orders through the e-commerce platform." - - provides OrderRequest - requires OrderConfirmation -} - -external system StripeAPI { - title = "Stripe Payment API" - requires PaymentRequest - provides PaymentResult -} - system ECommerce { title = "E-Commerce Platform" description = "Customer-facing online store." - // Channels decouple providers from requirers — no explicit wiring between pairs - channel order_in: OrderRequest - channel order_out: OrderConfirmation - channel payment: PaymentRequest { - protocol = "gRPC" - async = true - } - channel inventory: InventoryCheck { - protocol = "HTTP" - } - user Customer { - provides OrderRequest via order_in - requires OrderConfirmation via order_out + provides OrderRequest + requires OrderConfirmation } component OrderService { title = "Order Service" description = "Accepts, validates, and processes customer orders." - // Internal channel wires Validator to Processor without naming either - channel validation: ValidationResult - component Validator { requires OrderRequest - provides ValidationResult via validation + provides ValidationResult } component Processor { - requires ValidationResult via validation + requires ValidationResult requires PaymentRequest requires InventoryCheck provides OrderConfirmation } - // Unbound ports (OrderRequest, PaymentRequest, etc.) are visible at the boundary + // Internal wiring: Validator feeds Processor via an implicit channel + connect Validator.ValidationResult -> $validation -> Processor.ValidationResult + + // Remaining ports must be explicitly exposed at the OrderService boundary + expose Validator.OrderRequest + expose Processor.PaymentRequest + expose Processor.InventoryCheck + expose Processor.OrderConfirmation } component PaymentGateway { title = "Payment Gateway" tags = ["critical", "pci-scope"] - requires PaymentRequest via payment - provides PaymentResult + provides PaymentRequest } component InventoryManager { title = "Inventory Manager" - requires InventoryCheck via inventory - provides InventoryStatus + provides InventoryCheck + } + + // Wire customer to order pipeline + connect Customer.OrderRequest -> $order_in -> OrderService.OrderRequest + connect OrderService.OrderConfirmation -> $order_out -> Customer.OrderConfirmation + + // Wire OrderService to backing services + connect PaymentGateway.PaymentRequest -> $payment -> OrderService.PaymentRequest { + protocol = "gRPC" + async = true + } + connect InventoryManager.InventoryCheck -> $inventory -> OrderService.InventoryCheck { + protocol = "HTTP" } } ``` -Large architectures split naturally across files. A `from ... import` statement brings named definitions into scope; `use component X` places an imported component inside a system without redefining it. Remote repositories can be referenced with `@repo-name` prefixes for multi-repo workspace setups. +Large architectures split naturally across files. A `from ... import` statement brings named definitions into scope; `use component X` places an imported component inside a system without redefining it. Its exposed ports are available as `Entity.port_name` in `connect` and `expose` statements. Remote repositories can be referenced with `@repo-name` prefixes for multi-repo workspace setups. ## Language at a Glance -| Keyword | Purpose | -| ------------------------- | --------------------------------------------------------------------------------- | -| `system` | Group of components or sub-systems with a shared goal | -| `component` | Module with a clear responsibility; may nest sub-components | -| `user` | Human actor (role or persona) that interacts with the system | -| `interface` | Named contract of typed data fields; supports `@v1`, `@v2` versioning | -| `channel name: Interface` | Named conduit within a system or component scope; decouples providers from requirers | -| `type` | Reusable data structure (used within interfaces) | -| `enum` | Constrained set of named values | -| `field` | Named, typed data element with optional `description` and `schema` | -| `requires` / `provides` | Declare consumed and exposed interfaces on a component, system, or user | -| `requires X via channel` | Bind a `requires` declaration to a named channel | -| `provides X via channel` | Bind a `provides` declaration to a named channel | -| `external` | Marks a system, component, or user as outside the development boundary | -| `from … import` | Bring specific definitions from another file into scope | -| `use component X` | Place an imported entity inside a system | -| `tags` | Arbitrary labels for filtering and view generation | +| Keyword | Purpose | +| ---------------------------------- | --------------------------------------------------------------------------------- | +| `system` | Group of components or sub-systems with a shared goal | +| `component` | Module with a clear responsibility; may nest sub-components | +| `user` | Human actor (role or persona) that interacts with the system | +| `interface` | Named contract of typed data fields; supports `@v1`, `@v2` versioning | +| `type` | Reusable data structure (used within interfaces) | +| `enum` | Constrained set of named values | +| `field` | Named, typed data element with optional `description` and `schema` | +| `requires` / `provides` | Declare a port that consumes or exposes an interface | +| `requires X as port` | Assign an explicit name to a port | +| `connect A.p -> $ch -> B.p` | Wire two ports via a named implicit channel | +| `connect A.p -> B.p` | Wire two ports directly (no named channel) | +| `expose Entity.port [as name]` | Explicitly surface a sub-entity's port at the enclosing boundary | +| `external` | Marks a system, component, or user as outside the development boundary | +| `from … import` | Bring specific definitions from another file into scope | +| `use component X` | Place an imported entity inside a system | +| `tags` | Arbitrary labels for filtering and view generation | Primitive types: `String`, `Int`, `Float`, `Decimal`, `Bool`, `Bytes`, `Timestamp`, `Datetime` Container types: `List`, `Map`, `Optional` @@ -173,7 +164,7 @@ Delegates payment to PaymentGateway. """ ``` -Enum values and channel block attributes each occupy their own line — no commas needed. +Enum values each occupy their own line — no commas needed. Full syntax reference: [docs/LANGUAGE_SYNTAX.md](docs/LANGUAGE_SYNTAX.md) @@ -264,7 +255,7 @@ archml sync-remote ## Project Status -ArchML is in early development. The functional architecture domain (systems, components, interfaces, channels) is implemented. Behavioral and deployment domains are planned. +ArchML is in early development. The functional architecture domain (systems, components, interfaces, ports, and channels) is implemented. Behavioral and deployment domains are planned. See [docs/PROJECT_SCOPE.md](docs/PROJECT_SCOPE.md) for the full vision and roadmap. diff --git a/docs/LANGUAGE_SYNTAX.md b/docs/LANGUAGE_SYNTAX.md index 2b5498f..126b9df 100644 --- a/docs/LANGUAGE_SYNTAX.md +++ b/docs/LANGUAGE_SYNTAX.md @@ -132,11 +132,11 @@ interface OrderRequest @v2 { When a component requires or provides a versioned interface, it references the version explicitly (e.g., `requires OrderRequest @v2`). Unversioned references default to the latest version. -`interface` defines a contract used in channels. `type` defines a building block used within interfaces. Both share the same field syntax — the distinction is semantic: interfaces appear on channels; types compose into fields. +`interface` defines a contract used on ports. `type` defines a building block used within interfaces. Both share the same field syntax — the distinction is semantic: interfaces appear on ports; types compose into fields. ### Component -A component is a module with a clear responsibility. Components declare the interfaces they **require** (consume) and **provide** (expose). `requires` declarations always come before `provides`. +A component is a module with a clear responsibility. Components declare the interfaces they **require** (consume) and **provide** (expose) as **ports**. `requires` declarations always come before `provides`. ``` component OrderService { @@ -149,65 +149,85 @@ component OrderService { } ``` -Components can nest sub-components to express internal structure. Internal channels wire sub-components together without coupling them directly: +Each `requires` or `provides` declaration directly on an entity defines one of its **own ports** — a named connection point at its boundary. Own ports do not need `expose`; they are the entity's interface. By default, the port name equals the interface name. Use `as` to assign an explicit name when needed: ``` component OrderService { - title = "Order Service" + requires PaymentRequest as pay_in + requires InventoryCheck as inv_in + provides OrderConfirmation as confirmed +} +``` + +Components can nest sub-components to express internal structure. `connect` statements wire sub-components together without coupling them directly. Every port of every sub-component must either be wired by a `connect` or explicitly promoted to the enclosing boundary with `expose`: - channel validation: ValidationResult +``` +component OrderService { + title = "Order Service" component Validator { title = "Order Validator" requires OrderRequest - provides ValidationResult via validation + provides ValidationResult } component Processor { title = "Order Processor" - requires ValidationResult via validation + requires ValidationResult requires PaymentRequest requires InventoryCheck provides OrderConfirmation } + + // Wire Validator output to Processor input via an implicit channel + connect Validator.ValidationResult -> $validation -> Processor.ValidationResult + + // All remaining ports must be explicitly promoted to the OrderService boundary + expose Validator.OrderRequest + expose Processor.PaymentRequest + expose Processor.InventoryCheck + expose Processor.OrderConfirmation } ``` -The `via` clause binds a `requires` or `provides` declaration to a named channel. Components that don't bind to a channel have unbound interface declarations, which are visible at the enclosing scope boundary. +A port that is neither wired by `connect` nor promoted by `expose` is a validation error. ### System -A system groups components (or sub-systems) that work toward a shared goal. Systems declare **channels** that wire their members together without naming specific pairs. Systems may contain components and other systems, but components may not contain systems. +A system groups components (or sub-systems) that work toward a shared goal. Systems wire their members using `connect` statements. Systems may contain components and other systems, but components may not contain systems. ``` system ECommerce { title = "E-Commerce Platform" description = "Customer-facing online store." - channel payment: PaymentRequest { - protocol = "gRPC" - async = true - } - channel inventory: InventoryCheck { - protocol = "HTTP" - } - component OrderService { - requires PaymentRequest via payment - requires InventoryCheck via inventory + requires PaymentRequest + requires InventoryCheck provides OrderConfirmation } component PaymentGateway { - provides PaymentRequest via payment + provides PaymentRequest } component InventoryManager { - requires InventoryCheck via inventory - provides InventoryStatus + provides InventoryCheck + } + + connect PaymentGateway.PaymentRequest -> $payment -> OrderService.PaymentRequest { + protocol = "gRPC" + async = true + } + connect InventoryManager.InventoryCheck -> $inventory -> OrderService.InventoryCheck { + protocol = "HTTP" } + + // OrderService.OrderConfirmation has no internal consumer — expose it as the + // system's own boundary port + expose OrderService.OrderConfirmation } ``` @@ -217,15 +237,15 @@ Systems can nest other systems for large-scale decomposition: system Enterprise { title = "Enterprise Landscape" - channel inventory: InventorySync - system ECommerce { - provides InventorySync via inventory + provides InventorySync // declared directly — ECommerce's own boundary port } system Warehouse { - requires InventorySync via inventory + requires InventorySync // declared directly — Warehouse's own boundary port } + + connect ECommerce.InventorySync -> $inventory_sync -> Warehouse.InventorySync } ``` @@ -243,22 +263,22 @@ user Customer { } ``` -Users are leaf nodes — they cannot contain components or sub-users. A user participates in channels like any other entity: +Users are leaf nodes — they cannot contain components or sub-users. A user participates in `connect` statements like any other entity: ``` system ECommerce { - channel order_in: OrderRequest - channel order_out: OrderConfirmation - user Customer { - provides OrderRequest via order_in - requires OrderConfirmation via order_out + provides OrderRequest + requires OrderConfirmation } component OrderService { - requires OrderRequest via order_in - provides OrderConfirmation via order_out + requires OrderRequest + provides OrderConfirmation } + + connect Customer.OrderRequest -> $order_in -> OrderService.OrderRequest + connect OrderService.OrderConfirmation -> $order_out -> Customer.OrderConfirmation } ``` @@ -281,62 +301,134 @@ external user Admin { External entities appear in diagrams with distinct styling. They cannot be further decomposed (they are opaque). -## Channels +## Ports and Channels -A **channel** is a named conduit that carries a specific interface within a system or component scope. Channels decouple providers from requirers: each component binds to a channel by name without knowing who else is bound to it. +### Ports -### Channel declaration +Every `requires` and `provides` declaration defines a **port** — a named connection point on the entity. The port name defaults to the interface name; use `as` to assign an explicit name: -Channels are declared inside a system or component body: +``` +requires [@version] [as ] +provides [@version] [as ] +``` + +``` +requires PaymentRequest // port named "PaymentRequest" +requires PaymentRequest as pay_in // port named "pay_in" +provides OrderConfirmation as confirmed // port named "confirmed" +``` + +Port names must be unique within their entity. When two sub-entities have ports with the same name and both are promoted via `expose`, use `as` to disambiguate. + +### Channels and the `connect` Statement + +A **channel** is a named conduit between ports. Channels are introduced implicitly by `connect` statements — there is no separate channel declaration. Channel names use the `$` prefix to distinguish them from port names. + +The `connect` statement has three forms: + +``` +// Full chain: introduces $channel and wires both ports in one statement +connect -> $ -> + +// One-sided: introduces or references $channel, wires one port +connect -> $ +connect $ -> + +// Direct: wires two ports without a named channel +connect -> +``` + +`` and `` are either: + +- `Entity.port_name` — a port on a named child entity +- `port_name` — a port on the current scope's own boundary + +The arrow direction follows data flow: a `provides` port (producer) is always on the left; a `requires` port (consumer) is always on the right. The tooling validates that the interface types on both sides of a channel are compatible. ``` -channel : [@version] [{ attributes }] +// Full chain — introduces $payment and wires both sides at once +connect PaymentGateway.PaymentRequest -> $payment -> OrderService.PaymentRequest + +// Multi-step — build up a channel across two statements (same result) +connect PaymentGateway.PaymentRequest -> $payment +connect $payment -> OrderService.PaymentRequest + +// Direct connection without a named channel +connect Validator.ValidationResult -> Processor.ValidationResult ``` +Channel attributes (`protocol`, `async`, `description`) can be set in an optional block on the `connect` statement that introduces the channel: + ``` -channel payment: PaymentRequest -channel feed: DataFeed @v2 { +connect PaymentGateway.PaymentRequest -> $payment -> OrderService.PaymentRequest { protocol = "gRPC" async = true - description = "Asynchronous data feed channel." + description = "Delegate payment processing to Stripe." } ``` -Channel attributes (each on its own line): - -| Attribute | Type | Purpose | -| ------------- | ------- | ------------------------------------------ | +| Attribute | Type | Purpose | +| ------------- | ------- | -------------------------------------------- | | `protocol` | string | Transport protocol (e.g. `"gRPC"`, `"HTTP"`) | -| `async` | boolean | Whether the channel is asynchronous | -| `description` | string | Human-readable explanation of the channel | +| `async` | boolean | Whether the channel is asynchronous | +| `description` | string | Human-readable explanation of the channel | -### Binding to a channel +### Port Exposure -A `requires` or `provides` declaration binds to a channel with the `via` keyword: +Every port of every sub-entity must be accounted for within its enclosing scope: either wired by a `connect` statement or explicitly promoted to the enclosing boundary with `expose`. A port that is neither wired nor exposed is a **validation error**. ``` -requires [@version] via -provides [@version] via +expose Entity.port_name [as new_name] ``` +`expose` promotes a sub-entity's port to the enclosing boundary, making it part of that scope's own interface. The optional `as` renames the port at the boundary: + ``` component OrderService { - requires PaymentRequest via payment // binds to the "payment" channel - requires InventoryCheck via inventory // binds to the "inventory" channel - provides OrderConfirmation // unbound — visible at the enclosing scope + component Processor { + requires PaymentRequest + provides OrderConfirmation + } + + expose Processor.PaymentRequest as pay_in // promoted and renamed + expose Processor.OrderConfirmation // promoted under the same name +} +``` + +`expose` composes across levels: a system can wire a component's exposed port directly, or expose it further up to the system's own boundary: + +``` +system ECommerce { + use component OrderService // OrderService exposes PaymentRequest + + component PaymentGateway { + provides PaymentRequest + } + + // Wire the exposed port — this also satisfies it (no separate expose needed) + connect PaymentGateway.PaymentRequest -> $payment -> OrderService.PaymentRequest } ``` -The `via` clause is optional. An unbound interface declaration is still valid — it represents an interface the entity exposes at its boundary for the enclosing scope to wire. +At the top level of a system, ports that are not wired to any sibling must be exposed to declare that the system itself requires or provides that interface from/to the outside world: -The tooling validates that: -- The channel name in `via` is declared in the same scope (system or component body). -- The interface type of the binding matches the channel's declared interface type. -- Channel names are unique within their scope. +``` +system ECommerce { + component OrderService { + requires OrderRequest + provides OrderConfirmation + } + + // No internal component provides OrderRequest or consumes OrderConfirmation — + // expose them as the system's own boundary: + expose OrderService.OrderRequest + expose OrderService.OrderConfirmation +} +``` ### Encapsulation -A channel declared inside a component is local to that component — it is not visible from outside. Components without `via` bindings expose their unbound `requires`/`provides` declarations at the enclosing boundary. +A channel introduced by a `connect` statement is local to the scope where it appears — it is not visible from outside. The `$` prefix makes channels syntactically distinct from ports, preventing accidental name collisions. ## Tags @@ -386,14 +478,18 @@ from components/order_service import OrderService system ECommerce { title = "E-Commerce Platform" - channel order_in: OrderRequest - channel order_out: OrderConfirmation - use component OrderService + + component PaymentGateway { + provides PaymentRequest + } + + // Wire the imported component's surfaced port to the inline component's port + connect PaymentGateway.PaymentRequest -> $payment -> OrderService.PaymentRequest } ``` -The `use` keyword only places an already-imported entity; it does not allow overriding fields or interfaces. +The `use` keyword places an already-imported entity in scope. Its exposed ports become available as `Entity.port_name` targets in `connect` and `expose` statements within the enclosing scope. ### Cross-Repository Imports @@ -528,66 +624,51 @@ interface ReportOutput { } // file: components/order_service.archml -from types import OrderItem, OrderRequest, ValidationResult, PaymentRequest, InventoryCheck, OrderConfirmation +from types import OrderRequest, ValidationResult, PaymentRequest, InventoryCheck, OrderConfirmation component OrderService { title = "Order Service" description = "Accepts, validates, and processes customer orders." - channel validation: ValidationResult - component Validator { title = "Order Validator" requires OrderRequest - provides ValidationResult via validation + provides ValidationResult } component Processor { title = "Order Processor" - requires ValidationResult via validation + requires ValidationResult requires PaymentRequest requires InventoryCheck provides OrderConfirmation } + + // Wire Validator to Processor internally + connect Validator.ValidationResult -> $validation -> Processor.ValidationResult + + // Expose the remaining ports at the OrderService boundary + expose Validator.OrderRequest + expose Processor.PaymentRequest + expose Processor.InventoryCheck + expose Processor.OrderConfirmation } // file: systems/ecommerce.archml from types import OrderRequest, OrderConfirmation, PaymentRequest, PaymentResult, InventoryCheck, InventoryStatus from components/order_service import OrderService -user Customer { - title = "Customer" - description = "An end user who places orders through the e-commerce platform." - - provides OrderRequest - requires OrderConfirmation -} - -external system StripeAPI { - title = "Stripe Payment API" - requires PaymentRequest - provides PaymentResult -} - system ECommerce { title = "E-Commerce Platform" - channel order_in: OrderRequest - channel order_out: OrderConfirmation - channel payment: PaymentRequest { - protocol = "gRPC" - async = true - description = "Delegate payment processing." - } - channel inventory: InventoryCheck { - protocol = "HTTP" - } - user Customer { - provides OrderRequest via order_in - requires OrderConfirmation via order_out + title = "Customer" + description = "An end user who places orders through the e-commerce platform." + + provides OrderRequest + requires OrderConfirmation } use component OrderService @@ -596,16 +677,39 @@ system ECommerce { title = "Payment Gateway" tags = ["critical", "pci-scope"] - requires PaymentRequest via payment - provides PaymentResult + provides PaymentRequest + requires PaymentResult } component InventoryManager { title = "Inventory Manager" - requires InventoryCheck via inventory - provides InventoryStatus + provides InventoryCheck + } + + external system StripeAPI { + title = "Stripe Payment API" + requires PaymentRequest + provides PaymentResult } + + // Wire Customer to OrderService + connect Customer.OrderRequest -> $order_in -> OrderService.OrderRequest + connect OrderService.OrderConfirmation -> $order_out -> Customer.OrderConfirmation + + // Wire OrderService to backing services + connect PaymentGateway.PaymentRequest -> $payment -> OrderService.PaymentRequest { + protocol = "gRPC" + async = true + description = "Delegate payment processing." + } + connect InventoryManager.InventoryCheck -> $inventory -> OrderService.InventoryCheck { + protocol = "HTTP" + } + + // Wire PaymentGateway to external Stripe + connect PaymentGateway.PaymentRequest -> $stripe -> StripeAPI.PaymentRequest + connect StripeAPI.PaymentResult -> $stripe_result -> PaymentGateway.PaymentResult } ``` @@ -617,15 +721,17 @@ system ECommerce { | `component` | Module with a clear responsibility; may nest sub-components. | | `user` | Human actor (role or persona) that interacts with the system; a leaf node. | | `interface` | Named contract of typed data fields. Supports versioning via `@v1`, `@v2`, etc. | -| `channel` | Named conduit that carries a specific interface within a system or component scope. | | `type` | Reusable data structure (used within interfaces). | | `enum` | Constrained set of named values. | | `field` | Named, typed data element. Supports `description` and `schema` annotations. | | `filetype` | Annotation on a `File` field specifying its format. | | `schema` | Free-text annotation describing expected content or format. | -| `requires` | Declares an interface an element consumes (listed before `provides`). | -| `provides` | Declares an interface an element exposes. | -| `via` | Binds a `requires` or `provides` declaration to a named channel (`requires X via channel`). | +| `requires` | Declares a port that consumes an interface (listed before `provides`). | +| `provides` | Declares a port that exposes an interface. | +| `as` | Assigns an explicit name to a port (`requires PaymentRequest as pay_in`). | +| `connect` | Wires ports together, optionally via a named channel (`connect A.p -> $ch -> B.p`). | +| `expose` | Explicitly surfaces a sub-entity's port at the enclosing boundary (`expose Entity.port [as name]`). | +| `$channel` | Channel name in a `connect` statement; `$` prefix distinguishes channels from ports. | | `from` | Introduces the source path in an import statement (`from path import Name`). | | `import` | Names the specific entities to bring into scope; always paired with `from` (`from path import Name`). | | `use` | Places an imported entity into a system or component (e.g., `use component X`). | From ef8a90cf21166fba6573d38cd9ea977aaff3928e Mon Sep 17 00:00:00 2001 From: Andi Hellmund Date: Sat, 14 Mar 2026 20:02:37 +0100 Subject: [PATCH 3/3] further changes --- src/archml/compiler/parser.py | 208 ++++++-- src/archml/compiler/scanner.py | 21 +- src/archml/compiler/semantic_analysis.py | 247 +++------- src/archml/model/__init__.py | 6 +- src/archml/model/entities.py | 56 ++- src/archml/validation/checks.py | 73 +-- src/archml/views/topology.py | 146 ++++-- tests/compiler/test_artifact.py | 52 +- tests/compiler/test_compiler_integration.py | 38 +- tests/compiler/test_parser.py | 465 +++++++++++------- tests/compiler/test_scanner.py | 102 ++-- tests/compiler/test_semantic_analysis.py | 147 +++--- .../undefined_connection_endpoint.archml | 18 +- tests/data/positive/compiler/system.archml | 5 +- .../positive/imports/ecommerce_system.archml | 16 +- tests/data/positive/nested_components.archml | 16 +- .../positive/system_with_connections.archml | 41 +- tests/model/test_model.py | 71 ++- tests/validation/test_checks.py | 152 +----- tests/views/backend/test_diagram.py | 40 +- tests/views/test_placement.py | 78 +-- tests/views/test_topology.py | 257 ++++++---- 22 files changed, 1192 insertions(+), 1063 deletions(-) diff --git a/src/archml/compiler/parser.py b/src/archml/compiler/parser.py index 3e18b30..ea0e783 100644 --- a/src/archml/compiler/parser.py +++ b/src/archml/compiler/parser.py @@ -9,9 +9,10 @@ from archml.compiler.scanner import Token, TokenType, tokenize from archml.model.entities import ( ArchFile, - ChannelDef, Component, + ConnectDef, EnumDef, + ExposeDef, ImportDeclaration, InterfaceDef, InterfaceRef, @@ -78,7 +79,8 @@ def parse(source: str) -> ArchFile: TokenType.COMPONENT, TokenType.USER, TokenType.INTERFACE, - TokenType.CHANNEL, + TokenType.CONNECT, + TokenType.EXPOSE, TokenType.TYPE, TokenType.ENUM, TokenType.FIELD, @@ -86,7 +88,7 @@ def parse(source: str) -> ArchFile: TokenType.SCHEMA, TokenType.REQUIRES, TokenType.PROVIDES, - TokenType.VIA, + TokenType.AS, TokenType.FROM, TokenType.IMPORT, TokenType.USE, @@ -137,6 +139,13 @@ def _peek_type(self) -> TokenType: """Return the token type of the current token.""" return self._tokens[self._pos].type + def _peek_type_at(self, offset: int) -> TokenType: + """Return the token type at position self._pos + offset (clamped to EOF).""" + idx = self._pos + offset + if idx >= len(self._tokens): + return TokenType.EOF + return self._tokens[idx].type + def _at_end(self) -> bool: """Return True if the current token is the EOF token.""" return self._peek_type() == TokenType.EOF @@ -173,7 +182,7 @@ def _expect_name_token(self) -> Token: """Consume the current token as a name. Accepts identifiers and keywords used in name positions (e.g. a field - named 'via'). Raises ParseError for structural tokens and EOF. + named 'as'). Raises ParseError for structural tokens and EOF. """ tok = self._current() if tok.type != TokenType.IDENTIFIER and tok.type not in _KEYWORD_TYPES: @@ -395,10 +404,12 @@ def _parse_component(self, is_external: bool) -> Component: comp.requires.append(self._parse_interface_ref(TokenType.REQUIRES)) elif self._check(TokenType.PROVIDES): comp.provides.append(self._parse_interface_ref(TokenType.PROVIDES)) - elif self._check(TokenType.CHANNEL): - comp.channels.append(self._parse_channel()) elif self._check(TokenType.COMPONENT): comp.components.append(self._parse_component(is_external=False)) + elif self._check(TokenType.CONNECT): + comp.connects.append(self._parse_connect()) + elif self._check(TokenType.EXPOSE): + comp.exposes.append(self._parse_expose()) elif self._check(TokenType.EXTERNAL): self._advance() # consume 'external' inner = self._current() @@ -441,14 +452,16 @@ def _parse_system(self, is_external: bool) -> System: system.requires.append(self._parse_interface_ref(TokenType.REQUIRES)) elif self._check(TokenType.PROVIDES): system.provides.append(self._parse_interface_ref(TokenType.PROVIDES)) - elif self._check(TokenType.CHANNEL): - system.channels.append(self._parse_channel()) elif self._check(TokenType.COMPONENT): system.components.append(self._parse_component(is_external=False)) elif self._check(TokenType.SYSTEM): system.systems.append(self._parse_system(is_external=False)) elif self._check(TokenType.USER): system.users.append(self._parse_user(is_external=False)) + elif self._check(TokenType.CONNECT): + system.connects.append(self._parse_connect()) + elif self._check(TokenType.EXPOSE): + system.exposes.append(self._parse_expose()) elif self._check(TokenType.EXTERNAL): self._advance() # consume 'external' inner = self._current() @@ -540,30 +553,74 @@ def _parse_user(self, is_external: bool) -> UserDef: return user # ------------------------------------------------------------------ - # Channel declarations + # Connect statements # ------------------------------------------------------------------ - def _parse_channel(self) -> ChannelDef: - """Parse: channel : [@version] [{ attrs }] + def _parse_connect(self) -> ConnectDef: + """Parse a connect statement. + + Four forms: + connect -> $ -> [{ attrs }] + connect -> $ [{ attrs }] + connect $ -> [{ attrs }] + connect -> [{ attrs }] - Attributes (each on its own line): - protocol = "..." - async = true|false - description = "..." + Where / is either ``Entity.port`` or just + ``port`` (port on the current scope's own boundary). """ - self._expect(TokenType.CHANNEL) - name_tok = self._expect(TokenType.IDENTIFIER) - self._expect(TokenType.COLON) - iface_name_tok = self._expect(TokenType.IDENTIFIER) - version: str | None = None - if self._check(TokenType.AT): - self._advance() - ver_tok = self._expect(TokenType.IDENTIFIER) - version = ver_tok.value - channel = ChannelDef( - name=name_tok.value, - interface=InterfaceRef(name=iface_name_tok.value, version=version), - ) + connect_tok = self._expect(TokenType.CONNECT) + connect_def = ConnectDef() + + # Parse left-hand side: either $channel or Entity.port / port + if self._check(TokenType.DOLLAR): + # Form: connect $channel -> + self._advance() # consume $ + ch_tok = self._expect(TokenType.IDENTIFIER) + connect_def = ConnectDef(channel=ch_tok.value) + self._expect(TokenType.ARROW) + entity, port = self._parse_port_ref(connect_tok) + connect_def = ConnectDef( + channel=ch_tok.value, + dst_entity=entity, + dst_port=port, + ) + else: + # Left side is a port reference + src_entity, src_port = self._parse_port_ref(connect_tok) + self._expect(TokenType.ARROW) + + if self._check(TokenType.DOLLAR): + # Form: connect -> $channel [-> ] + self._advance() # consume $ + ch_tok = self._expect(TokenType.IDENTIFIER) + if self._check(TokenType.ARROW): + self._advance() # consume -> + dst_entity, dst_port = self._parse_port_ref(connect_tok) + connect_def = ConnectDef( + src_entity=src_entity, + src_port=src_port, + channel=ch_tok.value, + dst_entity=dst_entity, + dst_port=dst_port, + ) + else: + # One-sided src + connect_def = ConnectDef( + src_entity=src_entity, + src_port=src_port, + channel=ch_tok.value, + ) + else: + # Direct: connect -> + dst_entity, dst_port = self._parse_port_ref(connect_tok) + connect_def = ConnectDef( + src_entity=src_entity, + src_port=src_port, + dst_entity=dst_entity, + dst_port=dst_port, + ) + + # Optional attribute block if self._check(TokenType.LBRACE): lbrace = self._advance() # consume { last_attr_line = lbrace.line @@ -571,42 +628,103 @@ def _parse_channel(self) -> ChannelDef: attr_tok = self._current() if attr_tok.line <= last_attr_line: raise ParseError( - "Channel attributes must each be on a new line", + "Connect attributes must each be on a new line", attr_tok.line, attr_tok.column, ) - self._parse_channel_attr(channel) + connect_def = self._parse_connect_attr(connect_def) last_attr_line = self._tokens[self._pos - 1].line self._expect(TokenType.RBRACE) - return channel - def _parse_channel_attr(self, channel: ChannelDef) -> None: - """Parse a single attribute inside a channel annotation block.""" + return connect_def + + def _parse_port_ref(self, ctx_tok: Token) -> tuple[str | None, str]: + """Parse a port reference: ``Entity.port`` or just ``port``. + + Returns a tuple ``(entity_name_or_None, port_name)``. + """ + name_tok = self._expect(TokenType.IDENTIFIER) + if self._check(TokenType.DOT): + self._advance() # consume . + port_tok = self._expect(TokenType.IDENTIFIER) + return (name_tok.value, port_tok.value) + return (None, name_tok.value) + + def _parse_connect_attr(self, connect_def: ConnectDef) -> ConnectDef: + """Parse a single attribute inside a connect annotation block. + + Returns an updated ConnectDef with the attribute applied. + """ tok = self._current() if tok.type == TokenType.DESCRIPTION: - channel.description = self._parse_string_attr(TokenType.DESCRIPTION) + description = self._parse_string_attr(TokenType.DESCRIPTION) + return ConnectDef( + src_entity=connect_def.src_entity, + src_port=connect_def.src_port, + channel=connect_def.channel, + dst_entity=connect_def.dst_entity, + dst_port=connect_def.dst_port, + protocol=connect_def.protocol, + is_async=connect_def.is_async, + description=description, + ) elif tok.type == TokenType.IDENTIFIER: attr_name = self._advance().value self._expect(TokenType.EQUALS) if attr_name == "protocol": str_tok = self._expect(TokenType.STRING) - channel.protocol = str_tok.value + return ConnectDef( + src_entity=connect_def.src_entity, + src_port=connect_def.src_port, + channel=connect_def.channel, + dst_entity=connect_def.dst_entity, + dst_port=connect_def.dst_port, + protocol=str_tok.value, + is_async=connect_def.is_async, + description=connect_def.description, + ) elif attr_name == "async": bool_tok = self._expect(TokenType.TRUE, TokenType.FALSE) - channel.is_async = bool_tok.type == TokenType.TRUE + return ConnectDef( + src_entity=connect_def.src_entity, + src_port=connect_def.src_port, + channel=connect_def.channel, + dst_entity=connect_def.dst_entity, + dst_port=connect_def.dst_port, + protocol=connect_def.protocol, + is_async=bool_tok.type == TokenType.TRUE, + description=connect_def.description, + ) else: raise ParseError( - f"Unknown channel attribute {attr_name!r}", + f"Unknown connect attribute {attr_name!r}", tok.line, tok.column, ) else: raise ParseError( - f"Unexpected token {tok.value!r} in channel annotation block", + f"Unexpected token {tok.value!r} in connect annotation block", tok.line, tok.column, ) + # ------------------------------------------------------------------ + # Expose statements + # ------------------------------------------------------------------ + + def _parse_expose(self) -> ExposeDef: + """Parse: expose . [as ]""" + self._expect(TokenType.EXPOSE) + entity_tok = self._expect(TokenType.IDENTIFIER) + self._expect(TokenType.DOT) + port_tok = self._expect(TokenType.IDENTIFIER) + as_name: str | None = None + if self._check(TokenType.AS): + self._advance() # consume 'as' + as_tok = self._expect(TokenType.IDENTIFIER) + as_name = as_tok.value + return ExposeDef(entity=entity_tok.value, port=port_tok.value, as_name=as_name) + # ------------------------------------------------------------------ # Field declarations # ------------------------------------------------------------------ @@ -679,7 +797,7 @@ def _parse_type_ref(self) -> TypeRef: # ------------------------------------------------------------------ def _parse_interface_ref(self, keyword: TokenType) -> InterfaceRef: - """Parse: requires/provides [@version] [via ]""" + """Parse: requires/provides [@version] [as ]""" self._expect(keyword) name_tok = self._expect(TokenType.IDENTIFIER) version: str | None = None @@ -687,12 +805,12 @@ def _parse_interface_ref(self, keyword: TokenType) -> InterfaceRef: self._advance() ver_tok = self._expect(TokenType.IDENTIFIER) version = ver_tok.value - via: str | None = None - if self._check(TokenType.VIA): - self._advance() # consume 'via' - via_tok = self._expect(TokenType.IDENTIFIER) - via = via_tok.value - return InterfaceRef(name=name_tok.value, version=version, via=via) + port_name: str | None = None + if self._check(TokenType.AS): + self._advance() # consume 'as' + alias_tok = self._expect(TokenType.IDENTIFIER) + port_name = alias_tok.value + return InterfaceRef(name=name_tok.value, version=version, port_name=port_name) # ------------------------------------------------------------------ # Common attribute parsers diff --git a/src/archml/compiler/scanner.py b/src/archml/compiler/scanner.py index ea609c7..bb829b0 100644 --- a/src/archml/compiler/scanner.py +++ b/src/archml/compiler/scanner.py @@ -22,7 +22,8 @@ class TokenType(enum.Enum): COMPONENT = "component" USER = "user" INTERFACE = "interface" - CHANNEL = "channel" + CONNECT = "connect" + EXPOSE = "expose" TYPE = "type" ENUM = "enum" FIELD = "field" @@ -30,7 +31,7 @@ class TokenType(enum.Enum): SCHEMA = "schema" REQUIRES = "requires" PROVIDES = "provides" - VIA = "via" + AS = "as" FROM = "from" IMPORT = "import" USE = "use" @@ -53,6 +54,9 @@ class TokenType(enum.Enum): EQUALS = "=" AT = "@" SLASH = "/" + ARROW = "->" + DOLLAR = "$" + DOT = "." # Literals STRING = "STRING" @@ -124,7 +128,8 @@ def tokenize(source: str) -> list[Token]: "component": TokenType.COMPONENT, "user": TokenType.USER, "interface": TokenType.INTERFACE, - "channel": TokenType.CHANNEL, + "connect": TokenType.CONNECT, + "expose": TokenType.EXPOSE, "type": TokenType.TYPE, "enum": TokenType.ENUM, "field": TokenType.FIELD, @@ -132,7 +137,7 @@ def tokenize(source: str) -> list[Token]: "schema": TokenType.SCHEMA, "requires": TokenType.REQUIRES, "provides": TokenType.PROVIDES, - "via": TokenType.VIA, + "as": TokenType.AS, "from": TokenType.FROM, "import": TokenType.IMPORT, "use": TokenType.USE, @@ -156,6 +161,8 @@ def tokenize(source: str) -> list[Token]: "=": TokenType.EQUALS, "@": TokenType.AT, "/": TokenType.SLASH, + "$": TokenType.DOLLAR, + ".": TokenType.DOT, } @@ -252,7 +259,11 @@ def _scan_token(self) -> None: line = self._line col = self._column - if ch in _SINGLE_CHAR_TOKENS: + if ch == "-" and self._peek() == ">": + self._advance() # - + self._advance() # > + self._tokens.append(Token(TokenType.ARROW, "->", line, col)) + elif ch in _SINGLE_CHAR_TOKENS: self._advance() self._tokens.append(Token(_SINGLE_CHAR_TOKENS[ch], ch, line, col)) elif ch == '"': diff --git a/src/archml/compiler/semantic_analysis.py b/src/archml/compiler/semantic_analysis.py index f490cc2..5a47dfe 100644 --- a/src/archml/compiler/semantic_analysis.py +++ b/src/archml/compiler/semantic_analysis.py @@ -13,7 +13,17 @@ from dataclasses import dataclass -from archml.model.entities import ArchFile, ChannelDef, Component, EnumDef, InterfaceDef, InterfaceRef, System, UserDef +from archml.model.entities import ( + ArchFile, + Component, + ConnectDef, + EnumDef, + ExposeDef, + InterfaceDef, + InterfaceRef, + System, + UserDef, +) from archml.model.types import FieldDef, ListTypeRef, MapTypeRef, NamedTypeRef, OptionalTypeRef, TypeRef # ############### @@ -50,12 +60,12 @@ def analyze( - Interface references in ``requires`` / ``provides`` must resolve to a known interface (locally defined or imported); locally-defined versioned references are checked against the actual declared version. - - Channel interface references follow the same rules as requires/provides. - - ``via`` bindings in requires/provides must reference a channel declared - in the same scope (system or component body). - - Duplicate channel names within a system or component scope. - Duplicate member names within nested components and systems. - Name conflicts between components and sub-systems within a system. + - Connect statements: entity references in ``Entity.port`` must name + a direct child of the enclosing scope. + - Expose statements: ``Entity`` must name a direct child of the + enclosing scope. - Import entity validation: when *resolved_imports* is provided, each entity named in a ``from ... import`` statement must actually be defined at the top level of the resolved source file. @@ -183,7 +193,6 @@ def analyze(self) -> list[SemanticError]: all_interface_plain_names, local_interface_defs, imported_names, - channel_names=set(), ) ) @@ -199,7 +208,6 @@ def _check_component( all_interface_names: set[str], local_interface_defs: dict[tuple[str, str | None], InterfaceDef], imported_names: set[str], - parent_channel_names: set[str] | None = None, ) -> list[SemanticError]: errors: list[SemanticError] = [] ctx = f"component '{comp.name}'" @@ -212,32 +220,7 @@ def _check_component( ) ) - # Check for duplicate channel names. - errors.extend( - _check_duplicate_names( - [ch.name for ch in comp.channels], - "Duplicate channel name '{}' in " + ctx, - ) - ) - - own_channel_names = {ch.name for ch in comp.channels} - # The full channel scope available to this component's requires/provides: - # its own channels plus any channels from the enclosing scope. - available_channels = own_channel_names | (parent_channel_names or set()) - - # Check channel interface references. - for channel in comp.channels: - errors.extend( - _check_channel( - ctx, - channel, - all_interface_names, - local_interface_defs, - imported_names, - ) - ) - - # Check requires / provides interface references and via bindings. + # Check requires / provides interface references. for ref in comp.requires: errors.extend( _check_interface_ref( @@ -247,7 +230,6 @@ def _check_component( local_interface_defs, imported_names, "requires", - available_channels, ) ) for ref in comp.provides: @@ -259,11 +241,17 @@ def _check_component( local_interface_defs, imported_names, "provides", - available_channels, ) ) - # Recurse into sub-components, passing this component's own channels. + # Check connect / expose statements. + child_names = {c.name for c in comp.components} + for conn in comp.connects: + errors.extend(_check_connect(ctx, conn, child_names)) + for exp in comp.exposes: + errors.extend(_check_expose(ctx, exp, child_names)) + + # Recurse into sub-components. for sub in comp.components: errors.extend( self._check_component( @@ -272,7 +260,6 @@ def _check_component( all_interface_names, local_interface_defs, imported_names, - parent_channel_names=own_channel_names, ) ) @@ -310,13 +297,6 @@ def _check_system( "Duplicate user name '{}' in " + ctx, ) ) - # Check for duplicate channel names. - errors.extend( - _check_duplicate_names( - [ch.name for ch in system.channels], - "Duplicate channel name '{}' in " + ctx, - ) - ) # Check for name conflicts between components, sub-systems, and users. comp_names = {c.name for c in system.components} @@ -327,21 +307,7 @@ def _check_system( for name in sorted((comp_names | sys_names) & user_names): errors.append(SemanticError(f"{ctx}: name '{name}' is used for both a user and a component or sub-system")) - channel_names = {ch.name for ch in system.channels} - - # Check channel interface references. - for channel in system.channels: - errors.extend( - _check_channel( - ctx, - channel, - all_interface_names, - local_interface_defs, - imported_names, - ) - ) - - # Check requires / provides interface references and via bindings. + # Check requires / provides interface references. for ref in system.requires: errors.extend( _check_interface_ref( @@ -351,7 +317,6 @@ def _check_system( local_interface_defs, imported_names, "requires", - channel_names, ) ) for ref in system.provides: @@ -363,21 +328,25 @@ def _check_system( local_interface_defs, imported_names, "provides", - channel_names, ) ) - # Recurse into children, passing the system's channel names so that - # inline sub-components and users can bind to them. + # Check connect / expose statements. + child_names = comp_names | sys_names | user_names + for conn in system.connects: + errors.extend(_check_connect(ctx, conn, child_names)) + for exp in system.exposes: + errors.extend(_check_expose(ctx, exp, child_names)) + + # Recurse into children. for comp in system.components: errors.extend( - _check_component_in_system( + self._check_component( comp, all_type_names, all_interface_names, local_interface_defs, imported_names, - channel_names, ) ) for sub_sys in system.systems: @@ -397,7 +366,6 @@ def _check_system( all_interface_names, local_interface_defs, imported_names, - channel_names, ) ) @@ -641,10 +609,8 @@ def _check_interface_ref( local_interface_defs: dict[tuple[str, str | None], InterfaceDef], imported_names: set[str], keyword: str, - channel_names: set[str], ) -> list[SemanticError]: - """Check that an interface reference resolves to a known interface and that - any ``via`` binding refers to a channel declared in the current scope. + """Check that an interface reference resolves to a known interface. When the referenced interface is locally defined (not imported), a versioned reference is additionally checked against the declared version. @@ -671,35 +637,32 @@ def _check_interface_ref( ) ) - # Check via binding when present. - if ref.via is not None and ref.via not in channel_names: - errors.append( - SemanticError( - f"{ctx}: '{keyword} {ref.name}{ver_str} via {ref.via}'" - f" — channel '{ref.via}' is not defined in this scope" - ) - ) + return errors + +def _check_connect( + ctx: str, + conn: ConnectDef, + child_names: set[str], +) -> list[SemanticError]: + """Check that entity references in a connect statement name direct children.""" + errors: list[SemanticError] = [] + if conn.src_entity is not None and conn.src_entity not in child_names: + errors.append(SemanticError(f"{ctx}: connect references unknown child entity '{conn.src_entity}'")) + if conn.dst_entity is not None and conn.dst_entity not in child_names: + errors.append(SemanticError(f"{ctx}: connect references unknown child entity '{conn.dst_entity}'")) return errors -def _check_channel( +def _check_expose( ctx: str, - channel: ChannelDef, - all_interface_names: set[str], - local_interface_defs: dict[tuple[str, str | None], InterfaceDef], - imported_names: set[str], + exp: ExposeDef, + child_names: set[str], ) -> list[SemanticError]: - """Check that a channel's interface reference resolves to a known interface.""" - return _check_interface_ref( - ctx, - channel.interface, - all_interface_names, - local_interface_defs, - imported_names, - f"channel '{channel.name}':", - channel_names=set(), # channels themselves don't bind via other channels - ) + """Check that the entity in an expose statement names a direct child.""" + if exp.entity not in child_names: + return [SemanticError(f"{ctx}: expose references unknown child entity '{exp.entity}'")] + return [] def _check_user( @@ -707,112 +670,16 @@ def _check_user( all_interface_names: set[str], local_interface_defs: dict[tuple[str, str | None], InterfaceDef], imported_names: set[str], - channel_names: set[str], ) -> list[SemanticError]: """Check requires/provides interface references on a user entity.""" errors: list[SemanticError] = [] ctx = f"user '{user.name}'" for ref in user.requires: errors.extend( - _check_interface_ref( - ctx, ref, all_interface_names, local_interface_defs, imported_names, "requires", channel_names - ) + _check_interface_ref(ctx, ref, all_interface_names, local_interface_defs, imported_names, "requires") ) for ref in user.provides: errors.extend( - _check_interface_ref( - ctx, ref, all_interface_names, local_interface_defs, imported_names, "provides", channel_names - ) - ) - return errors - - -def _check_component_in_system( - comp: Component, - all_type_names: set[str], - all_interface_names: set[str], - local_interface_defs: dict[tuple[str, str | None], InterfaceDef], - imported_names: set[str], - parent_channel_names: set[str], -) -> list[SemanticError]: - """Check a component that is declared inline within a system. - - The component's own channels are checked first, then requires/provides - are validated against both own channels and the parent system's channels. - """ - errors: list[SemanticError] = [] - ctx = f"component '{comp.name}'" - - # Check for duplicate sub-component names. - errors.extend( - _check_duplicate_names( - [c.name for c in comp.components], - "Duplicate sub-component name '{}' in " + ctx, - ) - ) - - # Check for duplicate channel names. - errors.extend( - _check_duplicate_names( - [ch.name for ch in comp.channels], - "Duplicate channel name '{}' in " + ctx, - ) - ) - - # The full channel scope available to this component's requires/provides - # is its own channels plus channels from the enclosing system. - own_channel_names = {ch.name for ch in comp.channels} - available_channels = own_channel_names | parent_channel_names - - # Check channel interface references. - for channel in comp.channels: - errors.extend( - _check_channel( - ctx, - channel, - all_interface_names, - local_interface_defs, - imported_names, - ) + _check_interface_ref(ctx, ref, all_interface_names, local_interface_defs, imported_names, "provides") ) - - # Check requires / provides with the combined channel scope. - for ref in comp.requires: - errors.extend( - _check_interface_ref( - ctx, - ref, - all_interface_names, - local_interface_defs, - imported_names, - "requires", - available_channels, - ) - ) - for ref in comp.provides: - errors.extend( - _check_interface_ref( - ctx, - ref, - all_interface_names, - local_interface_defs, - imported_names, - "provides", - available_channels, - ) - ) - - # Recurse into sub-components using only the component's own channels. - for sub in comp.components: - errors.extend( - _check_component_in_system( - sub, - all_type_names, - all_interface_names, - local_interface_defs, - imported_names, - own_channel_names, - ) - ) - return errors diff --git a/src/archml/model/__init__.py b/src/archml/model/__init__.py index 1d0af9b..2390a4c 100644 --- a/src/archml/model/__init__.py +++ b/src/archml/model/__init__.py @@ -5,9 +5,10 @@ from archml.model.entities import ( ArchFile, - ChannelDef, Component, + ConnectDef, EnumDef, + ExposeDef, ImportDeclaration, InterfaceDef, InterfaceRef, @@ -45,7 +46,8 @@ "EnumDef", "TypeDef", "InterfaceDef", - "ChannelDef", + "ConnectDef", + "ExposeDef", "Component", "System", "UserDef", diff --git a/src/archml/model/entities.py b/src/archml/model/entities.py index cbb1aa7..1f9328b 100644 --- a/src/archml/model/entities.py +++ b/src/archml/model/entities.py @@ -16,11 +16,16 @@ class InterfaceRef(BaseModel): - """A reference to an interface by name, optionally pinned to a version and bound to a channel.""" + """A reference to an interface by name, optionally pinned to a version. + + The optional ``port_name`` holds the explicit port alias assigned with the + ``as`` keyword (e.g. ``requires PaymentRequest as pay_in``). When absent + the effective port name defaults to the interface name. + """ name: str version: str | None = None - via: str | None = None + port_name: str | None = None class EnumDef(BaseModel): @@ -55,20 +60,49 @@ class InterfaceDef(BaseModel): qualified_name: str = "" -class ChannelDef(BaseModel): - """A named conduit that carries a specific interface within a system or component scope. +class ConnectDef(BaseModel): + """A ``connect`` statement wiring ports, optionally via a named channel. + + Each of the four syntactic forms is encoded as follows: - Channels decouple providers from requirers: components bind to a channel - by name rather than referencing each other directly. + - Full chain ``connect A.p -> $ch -> B.q``: + ``src_entity="A"``, ``src_port="p"``, ``channel="ch"``, + ``dst_entity="B"``, ``dst_port="q"`` + - One-sided src ``connect A.p -> $ch``: + ``src_entity="A"``, ``src_port="p"``, ``channel="ch"``, + ``dst_entity=None``, ``dst_port=None`` + - One-sided dst ``connect $ch -> B.q``: + ``src_entity=None``, ``src_port=None``, ``channel="ch"``, + ``dst_entity="B"``, ``dst_port="q"`` + - Direct ``connect A.p -> B.q``: + ``src_entity="A"``, ``src_port="p"``, ``channel=None``, + ``dst_entity="B"``, ``dst_port="q"`` + + For a port on the current scope's own boundary (no entity qualifier), + ``src_entity`` / ``dst_entity`` is ``None``. """ - name: str - interface: InterfaceRef + src_entity: str | None = None + src_port: str | None = None + channel: str | None = None + dst_entity: str | None = None + dst_port: str | None = None protocol: str | None = None is_async: bool = False description: str | None = None +class ExposeDef(BaseModel): + """An ``expose`` statement promoting a sub-entity's port to the enclosing boundary. + + ``expose Entity.port_name [as new_name]`` + """ + + entity: str + port: str + as_name: str | None = None + + class UserDef(BaseModel): """A human actor (role or persona) that interacts with the system. @@ -95,8 +129,9 @@ class Component(BaseModel): tags: list[str] = _Field(default_factory=list) requires: list[InterfaceRef] = _Field(default_factory=list) provides: list[InterfaceRef] = _Field(default_factory=list) - channels: list[ChannelDef] = _Field(default_factory=list) components: list[Component] = _Field(default_factory=list) + connects: list[ConnectDef] = _Field(default_factory=list) + exposes: list[ExposeDef] = _Field(default_factory=list) is_external: bool = False qualified_name: str = "" @@ -110,10 +145,11 @@ class System(BaseModel): tags: list[str] = _Field(default_factory=list) requires: list[InterfaceRef] = _Field(default_factory=list) provides: list[InterfaceRef] = _Field(default_factory=list) - channels: list[ChannelDef] = _Field(default_factory=list) components: list[Component] = _Field(default_factory=list) systems: list[System] = _Field(default_factory=list) users: list[UserDef] = _Field(default_factory=list) + connects: list[ConnectDef] = _Field(default_factory=list) + exposes: list[ExposeDef] = _Field(default_factory=list) is_external: bool = False qualified_name: str = "" diff --git a/src/archml/validation/checks.py b/src/archml/validation/checks.py index 187c6d0..93a6d93 100644 --- a/src/archml/validation/checks.py +++ b/src/archml/validation/checks.py @@ -11,7 +11,7 @@ from dataclasses import dataclass, field -from archml.model.entities import ArchFile, Component, InterfaceRef, System, UserDef +from archml.model.entities import ArchFile, Component, InterfaceRef, System from archml.model.types import FieldDef, ListTypeRef, MapTypeRef, NamedTypeRef, OptionalTypeRef, TypeRef # ############### @@ -78,11 +78,6 @@ def validate(arch_file: ArchFile) -> ValidationResult: member (sub-component or sub-system) must declare the same interface. This ensures that upstream declarations are grounded in the hierarchy. - 3. **Unused channels** (warning): For every channel declared inside a - component or system scope, at least one direct member must bind to it - via a ``via`` reference. A channel that nothing binds to indicates an - incomplete or dead architectural connection. - Args: arch_file: The resolved ArchFile to validate. Qualified names should be assigned prior to calling this function (e.g., via semantic @@ -97,8 +92,6 @@ def validate(arch_file: ArchFile) -> ValidationResult: errors.extend(_check_type_cycles(arch_file)) errors.extend(_check_interface_propagation(arch_file)) - warnings.extend(_check_unused_channels(arch_file)) - return ValidationResult(warnings=warnings, errors=errors) @@ -273,67 +266,3 @@ def _check_system(system: System) -> None: _check_component(component) return errors - - -def _collect_via_names(members: list[Component | System | UserDef]) -> set[str]: - """Collect all ``via`` channel names referenced in any member's requires/provides.""" - result: set[str] = set() - for m in members: - for ref in m.requires: - if ref.via is not None: - result.add(ref.via) - for ref in m.provides: - if ref.via is not None: - result.add(ref.via) - return result - - -def _check_unused_channels(arch_file: ArchFile) -> list[ValidationWarning]: - """Return warnings for channels in a scope that no sub-entity binds to. - - For every channel declared inside a component or system scope, at least - one direct member must reference the channel via a ``via`` clause on a - ``requires`` or ``provides`` declaration. A channel that nothing binds - to indicates an incomplete architectural connection. - - Scopes with no members (leaf entities) are not checked. - """ - warnings: list[ValidationWarning] = [] - - def _check_component_channels(comp: Component) -> None: - if comp.channels and comp.components: - via_names = _collect_via_names(list(comp.components)) - label = _entity_label(comp.name, comp.qualified_name) - for ch in comp.channels: - if ch.name not in via_names: - warnings.append( - ValidationWarning( - message=f"Channel '{ch.name}' in '{label}' is not bound by any sub-component." - ) - ) - for sub in comp.components: - _check_component_channels(sub) - - def _check_system_channels(system: System) -> None: - all_members: list[Component | System | UserDef] = ( - list(system.components) + list(system.systems) + list(system.users) - ) - if system.channels and all_members: - via_names = _collect_via_names(all_members) - label = _entity_label(system.name, system.qualified_name) - for ch in system.channels: - if ch.name not in via_names: - warnings.append( - ValidationWarning(message=f"Channel '{ch.name}' in '{label}' is not bound by any member.") - ) - for sub in system.systems: - _check_system_channels(sub) - for comp in system.components: - _check_component_channels(comp) - - for system in arch_file.systems: - _check_system_channels(system) - for comp in arch_file.components: - _check_component_channels(comp) - - return warnings diff --git a/src/archml/views/topology.py b/src/archml/views/topology.py index 55384e7..fb03f25 100644 --- a/src/archml/views/topology.py +++ b/src/archml/views/topology.py @@ -34,7 +34,7 @@ from dataclasses import dataclass, field from typing import Literal -from archml.model.entities import Component, InterfaceRef, System, UserDef +from archml.model.entities import Component, ConnectDef, InterfaceRef, System, UserDef # ############### # Public Interface @@ -233,10 +233,9 @@ def build_viz_diagram( :class:`VizNode` instances in ``peripheral_nodes`` — one node per interface, positioned at the diagram boundary. - For each channel declared on the entity, components that bind to it via a - ``requires X via channel`` or ``provides X via channel`` declaration become - the endpoints of a :class:`VizEdge`. All providers and requirers of the - same channel are cross-connected pairwise. + Each ``connect`` statement on the entity produces a :class:`VizEdge` + between the two wired ports. One-sided connects (only a source or only + a destination) are skipped — they do not produce edges in the diagram. Args: entity: The focus component or system to visualize. @@ -282,7 +281,7 @@ def build_viz_diagram( for ref in entity.provides: peripheral_nodes.append(_make_terminal_node(ref, "provides")) - # --- Sub-entity map for channel binding lookup --- + # --- Sub-entity map for connect statement port lookup --- all_sub_entity_map: dict[str, Component | System | UserDef] = {} for comp in entity.components: all_sub_entity_map[comp.name] = comp @@ -292,53 +291,12 @@ def build_viz_diagram( for user in entity.users: all_sub_entity_map[user.name] = user - # --- Edges (derived from channel bindings) --- + # --- Edges (derived from connect statements) --- edges: list[VizEdge] = [] - for channel in entity.channels: - providers: list[tuple[str, InterfaceRef]] = [] - requirers: list[tuple[str, InterfaceRef]] = [] - for sub_name, sub_entity in all_sub_entity_map.items(): - for ref in sub_entity.provides: - if ref.via == channel.name: - providers.append((sub_name, ref)) - for ref in sub_entity.requires: - if ref.via == channel.name: - requirers.append((sub_name, ref)) - - for req_name, req_ref in requirers: - for prov_name, prov_ref in providers: - req_node = child_node_map.get(req_name) - prov_node = child_node_map.get(prov_name) - if req_node is None or prov_node is None: - continue - - src_port_id = _find_port_id(req_node, "requires", req_ref) - tgt_port_id = _find_port_id(prov_node, "provides", prov_ref) - - if src_port_id is None: - p = _make_port(req_node.id, "requires", req_ref) - req_node.ports.append(p) - src_port_id = p.id - - if tgt_port_id is None: - p = _make_port(prov_node.id, "provides", prov_ref) - prov_node.ports.append(p) - tgt_port_id = p.id - - label = _iref_label(channel.interface) - edges.append( - VizEdge( - id=f"edge.{src_port_id}--{tgt_port_id}", - source_port_id=src_port_id, - target_port_id=tgt_port_id, - label=label, - interface_name=channel.interface.name, - interface_version=channel.interface.version, - protocol=channel.protocol, - is_async=channel.is_async, - description=channel.description, - ) - ) + for conn in entity.connects: + edge = _build_edge_from_connect(conn, child_node_map, all_sub_entity_map) + if edge is not None: + edges.append(edge) return VizDiagram( id=f"diagram.{root_id}", @@ -482,6 +440,90 @@ def _find_port_id( return None +def _find_ref_by_port_name( + entity: Component | System | UserDef, + port_name: str, +) -> tuple[Literal["requires", "provides"], InterfaceRef] | None: + """Find the direction and interface ref for a named port on *entity*. + + The effective port name is ``ref.port_name`` when explicitly aliased with + ``as``, otherwise the interface name ``ref.name``. + + Returns a ``(direction, ref)`` tuple, or ``None`` if not found. + """ + for ref in entity.requires: + effective = ref.port_name if ref.port_name else ref.name + if effective == port_name: + return ("requires", ref) + for ref in entity.provides: + effective = ref.port_name if ref.port_name else ref.name + if effective == port_name: + return ("provides", ref) + return None + + +def _build_edge_from_connect( + conn: ConnectDef, + child_node_map: dict[str, VizNode], + sub_entity_map: dict[str, Component | System | UserDef], +) -> VizEdge | None: + """Attempt to build a :class:`VizEdge` from a :class:`ConnectDef`. + + Returns ``None`` for one-sided connects (no src or no dst) and for + connects whose entity references cannot be resolved. + """ + # One-sided connects don't produce edges. + if conn.src_entity is None or conn.src_port is None: + return None + if conn.dst_entity is None or conn.dst_port is None: + return None + + src_sub = sub_entity_map.get(conn.src_entity) + dst_sub = sub_entity_map.get(conn.dst_entity) + if src_sub is None or dst_sub is None: + return None + + src_result = _find_ref_by_port_name(src_sub, conn.src_port) + dst_result = _find_ref_by_port_name(dst_sub, conn.dst_port) + if src_result is None or dst_result is None: + return None + + src_dir, src_ref = src_result + dst_dir, dst_ref = dst_result + + src_node = child_node_map.get(conn.src_entity) + dst_node = child_node_map.get(conn.dst_entity) + if src_node is None or dst_node is None: + return None + + src_port_id = _find_port_id(src_node, src_dir, src_ref) + dst_port_id = _find_port_id(dst_node, dst_dir, dst_ref) + + if src_port_id is None: + p = _make_port(src_node.id, src_dir, src_ref) + src_node.ports.append(p) + src_port_id = p.id + + if dst_port_id is None: + p = _make_port(dst_node.id, dst_dir, dst_ref) + dst_node.ports.append(p) + dst_port_id = p.id + + # Use src_ref for the edge label (both sides should carry the same interface). + label = _iref_label(src_ref) + return VizEdge( + id=f"edge.{src_port_id}--{dst_port_id}", + source_port_id=src_port_id, + target_port_id=dst_port_id, + label=label, + interface_name=src_ref.name, + interface_version=src_ref.version, + protocol=conn.protocol, + is_async=conn.is_async, + description=conn.description, + ) + + def _collect_boundary_ports(boundary: VizBoundary, result: dict[str, VizPort]) -> None: """Recursively collect all ports from *boundary* and its children into *result*.""" for p in boundary.ports: diff --git a/tests/compiler/test_artifact.py b/tests/compiler/test_artifact.py index 14a686a..782d2f8 100644 --- a/tests/compiler/test_artifact.py +++ b/tests/compiler/test_artifact.py @@ -11,9 +11,10 @@ from archml.compiler.parser import parse from archml.model.entities import ( ArchFile, - ChannelDef, Component, + ConnectDef, EnumDef, + ExposeDef, ImportDeclaration, InterfaceDef, InterfaceRef, @@ -266,7 +267,7 @@ def test_nested_component(self) -> None: result = _roundtrip(af) assert result.components[0].components[0].name == "Child" - def test_channel_roundtrip(self) -> None: + def test_connect_roundtrip(self) -> None: af = ArchFile( components=[ Component( @@ -274,32 +275,55 @@ def test_channel_roundtrip(self) -> None: components=[ Component( name="A", - provides=[InterfaceRef(name="Signal", via="sig_ch")], + provides=[InterfaceRef(name="Signal")], ), Component( name="B", - requires=[InterfaceRef(name="Signal", via="sig_ch")], + requires=[InterfaceRef(name="Signal")], ), ], - channels=[ - ChannelDef( - name="sig_ch", - interface=InterfaceRef(name="Signal"), + connects=[ + ConnectDef( + src_entity="A", + src_port="Signal", + channel="sig_ch", + dst_entity="B", + dst_port="Signal", protocol="gRPC", is_async=True, description="Data flow.", ) ], + exposes=[], ) ] ) result = _roundtrip(af) - ch = result.components[0].channels[0] - assert ch.name == "sig_ch" - assert ch.interface.name == "Signal" - assert ch.protocol == "gRPC" - assert ch.is_async - assert ch.description == "Data flow." + conn = result.components[0].connects[0] + assert conn.src_entity == "A" + assert conn.src_port == "Signal" + assert conn.channel == "sig_ch" + assert conn.dst_entity == "B" + assert conn.dst_port == "Signal" + assert conn.protocol == "gRPC" + assert conn.is_async + assert conn.description == "Data flow." + + def test_expose_roundtrip(self) -> None: + af = ArchFile( + components=[ + Component( + name="Parent", + components=[Component(name="Inner", requires=[InterfaceRef(name="X")])], + exposes=[ExposeDef(entity="Inner", port="X", as_name="x_port")], + ) + ] + ) + result = _roundtrip(af) + exp = result.components[0].exposes[0] + assert exp.entity == "Inner" + assert exp.port == "X" + assert exp.as_name == "x_port" class TestSystems: diff --git a/tests/compiler/test_compiler_integration.py b/tests/compiler/test_compiler_integration.py index 16d2e65..e09758a 100644 --- a/tests/compiler/test_compiler_integration.py +++ b/tests/compiler/test_compiler_integration.py @@ -178,12 +178,12 @@ def test_undefined_interface_ref(self) -> None: "refers to unknown interface 'ExternalFeed'", ) - def test_undefined_via_channel(self) -> None: + def test_undefined_connection_endpoint(self) -> None: path = NEGATIVE_DIR / "undefined_connection_endpoint.archml" _assert_errors( path, - "ghost_ch", - "missing_ch", + "Ghost", + "Missing", ) def test_wrong_interface_version(self) -> None: @@ -320,15 +320,16 @@ def test_large_types_file_parses_and_passes(self) -> None: system ECommerce { component OrderServiceInst { requires OrderRequest @v2 - requires PaymentRequest via payment + requires PaymentRequest provides OrderConfirmation } - channel payment: PaymentRequest component PaymentGatewayInst { - provides PaymentRequest via payment + provides PaymentRequest provides PaymentResult } + + connect PaymentGatewayInst.PaymentRequest -> $payment -> OrderServiceInst.PaymentRequest } """ arch_file = parse(source) @@ -371,14 +372,13 @@ def test_deeply_nested_system_structure(self) -> None: system Outer { system Middle { - channel sig_ch: Signal - component Inner { - provides Signal via sig_ch + provides Signal } component Sink { - requires Signal via sig_ch + requires Signal } + connect Inner.Signal -> $sig_ch -> Sink.Signal } } """ @@ -394,14 +394,15 @@ def test_deeply_nested_with_error(self) -> None: system Outer { system Middle { component Inner { - provides Signal via ghost_ch + provides Signal } + connect Ghost.Signal -> Inner.Signal } } """ arch_file = parse(source) errors = analyze(arch_file) - assert any("ghost_ch" in e.message for e in errors) + assert any("Ghost" in e.message for e in errors) def test_file_and_directory_type_refs_are_valid(self) -> None: """File and Directory types require no resolution and are always valid.""" @@ -421,7 +422,7 @@ def test_file_and_directory_type_refs_are_valid(self) -> None: assert errors == [], f"Expected clean but got: {[e.message for e in errors]}" def test_use_statement_adds_component_to_scope(self) -> None: - """Components added via 'use' are valid channel binding targets.""" + """Components added via 'use' are valid connect targets.""" source = """ from services import OrderService @@ -429,16 +430,17 @@ def test_use_statement_adds_component_to_scope(self) -> None: system ECommerce { use component OrderService - channel payment: PaymentRequest component PaymentGateway { - requires PaymentRequest via payment + provides PaymentRequest } + + connect PaymentGateway.PaymentRequest -> $payment -> OrderService.PaymentRequest } """ arch_file = parse(source) errors = analyze(arch_file) # OrderService is in the imported names list, and 'use component' adds it - # as a stub component in the system — no via binding errors expected here. - via_errors = [e for e in errors if "is not defined in this scope" in e.message] - assert via_errors == [], f"Via channel errors: {via_errors}" + # as a stub component in the system — no connect errors expected here. + connect_errors = [e for e in errors if "unknown child entity" in e.message] + assert connect_errors == [], f"Connect errors: {connect_errors}" diff --git a/tests/compiler/test_parser.py b/tests/compiler/test_parser.py index e49cb3d..b575bf4 100644 --- a/tests/compiler/test_parser.py +++ b/tests/compiler/test_parser.py @@ -8,7 +8,8 @@ from archml.compiler.parser import ParseError, parse from archml.model.entities import ( ArchFile, - ChannelDef, + ConnectDef, + ExposeDef, ) from archml.model.types import ( DirectoryTypeRef, @@ -625,7 +626,8 @@ def test_empty_component(self) -> None: assert comp.requires == [] assert comp.provides == [] assert comp.components == [] - assert comp.channels == [] + assert comp.connects == [] + assert comp.exposes == [] def test_component_with_title(self) -> None: result = _parse('component OrderService { title = "Order Service" }') @@ -728,21 +730,23 @@ def test_deeply_nested_components(self) -> None: assert c.name == "C" assert c.requires[0].name == "X" - def test_component_with_channel(self) -> None: + def test_component_with_connect(self) -> None: source = """\ component OrderService { - channel validation: ValidationResult - component Validator { provides ValidationResult via validation } - component Processor { requires ValidationResult via validation } + component Validator { provides ValidationResult } + component Processor { requires ValidationResult } + connect Validator.ValidationResult -> $validation -> Processor.ValidationResult }""" result = _parse(source) comp = result.components[0] - assert len(comp.channels) == 1 - ch = comp.channels[0] - assert ch.name == "validation" - assert ch.interface.name == "ValidationResult" - assert comp.components[0].provides[0].via == "validation" - assert comp.components[1].requires[0].via == "validation" + assert len(comp.connects) == 1 + conn = comp.connects[0] + assert isinstance(conn, ConnectDef) + assert conn.src_entity == "Validator" + assert conn.src_port == "ValidationResult" + assert conn.channel == "validation" + assert conn.dst_entity == "Processor" + assert conn.dst_port == "ValidationResult" def test_external_component(self) -> None: result = _parse("external component StripeSDK { requires PaymentRequest }") @@ -779,7 +783,8 @@ def test_empty_system(self) -> None: assert system.is_external is False assert system.components == [] assert system.systems == [] - assert system.channels == [] + assert system.connects == [] + assert system.exposes == [] def test_system_with_title(self) -> None: result = _parse('system ECommerce { title = "E-Commerce Platform" }') @@ -822,28 +827,35 @@ def test_system_with_multiple_components(self) -> None: "InventoryManager", ] - def test_system_with_channel(self) -> None: + def test_system_with_connect(self) -> None: source = """\ system ECommerce { - channel payment: PaymentRequest - component A { provides PaymentRequest via payment } - component B { requires PaymentRequest via payment } + component A { provides PaymentRequest } + component B { requires PaymentRequest } + connect A.PaymentRequest -> $payment -> B.PaymentRequest }""" result = _parse(source) system = result.systems[0] - assert len(system.channels) == 1 - ch = system.channels[0] - assert ch.name == "payment" - assert ch.interface.name == "PaymentRequest" - - def test_system_with_multiple_channels(self) -> None: + assert len(system.connects) == 1 + conn = system.connects[0] + assert conn.src_entity == "A" + assert conn.src_port == "PaymentRequest" + assert conn.channel == "payment" + assert conn.dst_entity == "B" + assert conn.dst_port == "PaymentRequest" + + def test_system_with_multiple_connects(self) -> None: source = """\ system ECommerce { - channel payment: PaymentRequest - channel inventory: InventoryCheck + component A { provides PaymentRequest } + component B { requires PaymentRequest } + component C { provides InventoryCheck } + component D { requires InventoryCheck } + connect A.PaymentRequest -> $payment -> B.PaymentRequest + connect C.InventoryCheck -> $inventory -> D.InventoryCheck }""" result = _parse(source) - assert len(result.systems[0].channels) == 2 + assert len(result.systems[0].connects) == 2 def test_system_with_nested_system(self) -> None: source = """\ @@ -933,183 +945,289 @@ def test_system_defaults(self) -> None: assert system.tags == [] assert system.is_external is False - def test_nested_system_with_channel(self) -> None: + def test_nested_system_with_connect(self) -> None: source = """\ system Enterprise { title = "Enterprise Landscape" - channel inventory: InventorySync - system ECommerce {} - system Warehouse {} + system ECommerce { provides InventorySync } + system Warehouse { requires InventorySync } + connect ECommerce.InventorySync -> $inventory -> Warehouse.InventorySync }""" result = _parse(source) system = result.systems[0] assert system.title == "Enterprise Landscape" assert len(system.systems) == 2 - assert len(system.channels) == 1 - assert system.channels[0].interface.name == "InventorySync" + assert len(system.connects) == 1 + assert system.connects[0].channel == "inventory" # ############### -# Channel Declarations +# Connect Statements # ############### -class TestChannelDeclarations: - def test_simple_channel(self) -> None: - result = _parse("system S { channel payment: PaymentRequest }") - ch = result.systems[0].channels[0] - assert isinstance(ch, ChannelDef) - assert ch.name == "payment" - assert ch.interface.name == "PaymentRequest" - assert ch.interface.version is None - assert ch.protocol is None - assert ch.is_async is False - assert ch.description is None - - def test_channel_with_versioned_interface(self) -> None: - result = _parse("system S { channel payment: PaymentRequest @v2 }") - ch = result.systems[0].channels[0] - assert ch.interface.name == "PaymentRequest" - assert ch.interface.version == "v2" - - def test_channel_with_protocol(self) -> None: +class TestConnectStatements: + def test_full_chain_connect(self) -> None: + source = """\ +system S { + component A { provides PaymentRequest } + component B { requires PaymentRequest } + connect A.PaymentRequest -> $payment -> B.PaymentRequest +}""" + result = _parse(source) + conn = result.systems[0].connects[0] + assert isinstance(conn, ConnectDef) + assert conn.src_entity == "A" + assert conn.src_port == "PaymentRequest" + assert conn.channel == "payment" + assert conn.dst_entity == "B" + assert conn.dst_port == "PaymentRequest" + + def test_direct_connect_no_channel(self) -> None: + source = """\ +system S { + component A { provides ValidationResult } + component B { requires ValidationResult } + connect A.ValidationResult -> B.ValidationResult +}""" + result = _parse(source) + conn = result.systems[0].connects[0] + assert conn.src_entity == "A" + assert conn.src_port == "ValidationResult" + assert conn.channel is None + assert conn.dst_entity == "B" + assert conn.dst_port == "ValidationResult" + + def test_one_sided_src_connect(self) -> None: + source = """\ +system S { + component A { provides PaymentRequest } + connect A.PaymentRequest -> $payment +}""" + result = _parse(source) + conn = result.systems[0].connects[0] + assert conn.src_entity == "A" + assert conn.src_port == "PaymentRequest" + assert conn.channel == "payment" + assert conn.dst_entity is None + assert conn.dst_port is None + + def test_one_sided_dst_connect(self) -> None: source = """\ system S { - channel payment: PaymentRequest { + component B { requires PaymentRequest } + connect $payment -> B.PaymentRequest +}""" + result = _parse(source) + conn = result.systems[0].connects[0] + assert conn.src_entity is None + assert conn.src_port is None + assert conn.channel == "payment" + assert conn.dst_entity == "B" + assert conn.dst_port == "PaymentRequest" + + def test_connect_with_protocol(self) -> None: + source = """\ +system S { + component A { provides PaymentRequest } + component B { requires PaymentRequest } + connect A.PaymentRequest -> $payment -> B.PaymentRequest { protocol = "gRPC" } }""" result = _parse(source) - ch = result.systems[0].channels[0] - assert ch.protocol == "gRPC" + conn = result.systems[0].connects[0] + assert conn.protocol == "gRPC" - def test_channel_with_async_true(self) -> None: + def test_connect_with_async_true(self) -> None: source = """\ system S { - channel payment: PaymentRequest { + component A { provides PaymentRequest } + component B { requires PaymentRequest } + connect A.PaymentRequest -> $payment -> B.PaymentRequest { async = true } }""" result = _parse(source) - assert result.systems[0].channels[0].is_async is True + assert result.systems[0].connects[0].is_async is True - def test_channel_with_async_false(self) -> None: + def test_connect_with_async_false(self) -> None: source = """\ system S { - channel payment: PaymentRequest { + component A { provides PaymentRequest } + component B { requires PaymentRequest } + connect A.PaymentRequest -> $payment -> B.PaymentRequest { async = false } }""" result = _parse(source) - assert result.systems[0].channels[0].is_async is False + assert result.systems[0].connects[0].is_async is False - def test_channel_with_description(self) -> None: + def test_connect_with_description(self) -> None: source = """\ system S { - channel payment: PaymentRequest { + component A { provides PaymentRequest } + component B { requires PaymentRequest } + connect A.PaymentRequest -> $payment -> B.PaymentRequest { description = "Carries payment processing requests." } }""" result = _parse(source) - assert result.systems[0].channels[0].description == "Carries payment processing requests." + assert result.systems[0].connects[0].description == "Carries payment processing requests." - def test_channel_with_all_annotations(self) -> None: + def test_connect_with_all_annotations(self) -> None: source = """\ system S { - channel payment: PaymentRequest { + component A { provides PaymentRequest } + component B { requires PaymentRequest } + connect A.PaymentRequest -> $payment -> B.PaymentRequest { protocol = "gRPC" async = true description = "Initiates payment processing for confirmed orders." } }""" result = _parse(source) - ch = result.systems[0].channels[0] - assert ch.name == "payment" - assert ch.interface.name == "PaymentRequest" - assert ch.protocol == "gRPC" - assert ch.is_async is True - assert ch.description == "Initiates payment processing for confirmed orders." + conn = result.systems[0].connects[0] + assert conn.channel == "payment" + assert conn.protocol == "gRPC" + assert conn.is_async is True + assert conn.description == "Initiates payment processing for confirmed orders." + + def test_connect_attr_on_same_line_as_lbrace_raises(self) -> None: + with pytest.raises(ParseError) as exc_info: + _parse('system S { component A {} component B {} connect A.X -> B.X { protocol = "HTTP" } }') + assert "new line" in str(exc_info.value) - def test_channel_with_http_protocol(self) -> None: + def test_connect_attrs_on_same_line_as_each_other_raises(self) -> None: source = """\ system S { - channel inventory: InventoryCheck { - protocol = "HTTP" + component A { provides X } + component B { requires X } + connect A.X -> $ch -> B.X { + protocol = "HTTP" async = true } }""" - result = _parse(source) - assert result.systems[0].channels[0].protocol == "HTTP" + with pytest.raises(ParseError) as exc_info: + _parse(source) + assert "new line" in str(exc_info.value) - def test_multiple_channels_in_system(self) -> None: + def test_multiple_connects_in_system(self) -> None: source = """\ system ECommerce { - channel payment: PaymentRequest { + component A { provides PaymentRequest } + component B { requires PaymentRequest } + component C { provides InventoryCheck } + component D { requires InventoryCheck } + connect A.PaymentRequest -> $payment -> B.PaymentRequest { protocol = "gRPC" async = true } - channel inventory: InventoryCheck { + connect C.InventoryCheck -> $inventory -> D.InventoryCheck { protocol = "HTTP" } - channel orders: OrderRequest + connect A.PaymentRequest -> B.PaymentRequest }""" result = _parse(source) - channels = result.systems[0].channels - assert len(channels) == 3 - assert channels[0].interface.name == "PaymentRequest" - assert channels[1].interface.name == "InventoryCheck" - assert channels[2].protocol is None + connects = result.systems[0].connects + assert len(connects) == 3 + assert connects[0].channel == "payment" + assert connects[1].channel == "inventory" + assert connects[2].channel is None - def test_channel_attr_on_same_line_as_lbrace_raises(self) -> None: - with pytest.raises(ParseError) as exc_info: - _parse('system S { channel payment: PaymentRequest { protocol = "HTTP" } }') - assert "new line" in str(exc_info.value) - - def test_channel_attrs_on_same_line_as_each_other_raises(self) -> None: + def test_connect_in_component(self) -> None: source = """\ -system S { - channel payment: PaymentRequest { - protocol = "HTTP" async = true - } +component OrderService { + component Validator { provides ValidationResult } + component Processor { requires ValidationResult } + connect Validator.ValidationResult -> $validation -> Processor.ValidationResult }""" - with pytest.raises(ParseError) as exc_info: - _parse(source) - assert "new line" in str(exc_info.value) + result = _parse(source) + comp = result.components[0] + assert len(comp.connects) == 1 + assert comp.connects[0].channel == "validation" - def test_requires_with_via(self) -> None: - result = _parse("component X { requires PaymentRequest via payment }") + def test_requires_with_as(self) -> None: + result = _parse("component X { requires PaymentRequest as pay_in }") ref = result.components[0].requires[0] assert ref.name == "PaymentRequest" - assert ref.via == "payment" + assert ref.port_name == "pay_in" - def test_provides_with_via(self) -> None: - result = _parse("component X { provides OrderConfirmation via confirm }") + def test_provides_with_as(self) -> None: + result = _parse("component X { provides OrderConfirmation as confirmed }") ref = result.components[0].provides[0] assert ref.name == "OrderConfirmation" - assert ref.via == "confirm" + assert ref.port_name == "confirmed" - def test_requires_versioned_with_via(self) -> None: - result = _parse("component X { requires PaymentRequest @v2 via payment }") + def test_requires_versioned_with_as(self) -> None: + result = _parse("component X { requires PaymentRequest @v2 as pay_in }") ref = result.components[0].requires[0] assert ref.name == "PaymentRequest" assert ref.version == "v2" - assert ref.via == "payment" + assert ref.port_name == "pay_in" - def test_requires_without_via_has_none(self) -> None: + def test_requires_without_as_has_none_port_name(self) -> None: result = _parse("component X { requires PaymentRequest }") ref = result.components[0].requires[0] - assert ref.via is None + assert ref.port_name is None + + +# ############### +# Expose Statements +# ############### + - def test_channel_in_component(self) -> None: +class TestExposeStatements: + def test_simple_expose(self) -> None: source = """\ component OrderService { - channel validation: ValidationResult - component Validator { provides ValidationResult via validation } - component Processor { requires ValidationResult via validation } + component Processor { requires PaymentRequest } + expose Processor.PaymentRequest }""" result = _parse(source) - comp = result.components[0] - assert len(comp.channels) == 1 - assert comp.channels[0].name == "validation" + exp = result.components[0].exposes[0] + assert isinstance(exp, ExposeDef) + assert exp.entity == "Processor" + assert exp.port == "PaymentRequest" + assert exp.as_name is None + + def test_expose_with_as(self) -> None: + source = """\ +component OrderService { + component Processor { requires PaymentRequest } + expose Processor.PaymentRequest as pay_in +}""" + result = _parse(source) + exp = result.components[0].exposes[0] + assert exp.entity == "Processor" + assert exp.port == "PaymentRequest" + assert exp.as_name == "pay_in" + + def test_multiple_exposes(self) -> None: + source = """\ +component OrderService { + component Validator { requires OrderRequest } + component Processor { requires PaymentRequest provides OrderConfirmation } + expose Validator.OrderRequest + expose Processor.PaymentRequest as pay_in + expose Processor.OrderConfirmation +}""" + result = _parse(source) + exposes = result.components[0].exposes + assert len(exposes) == 3 + assert exposes[0].entity == "Validator" + assert exposes[1].as_name == "pay_in" + assert exposes[2].as_name is None + + def test_expose_in_system(self) -> None: + source = """\ +system ECommerce { + component OrderService { provides OrderConfirmation } + expose OrderService.OrderConfirmation +}""" + result = _parse(source) + exp = result.systems[0].exposes[0] + assert exp.entity == "OrderService" + assert exp.port == "OrderConfirmation" # ############### @@ -1442,30 +1560,30 @@ def test_complete_spec_example_ecommerce_system(self) -> None: system ECommerce { title = "E-Commerce Platform" - channel payment: PaymentRequest { - protocol = "HTTP" - async = true - } - channel inventory: InventoryCheck { - protocol = "HTTP" - } - use component OrderService component PaymentGateway { title = "Payment Gateway" tags = ["critical", "pci-scope"] - requires PaymentRequest via payment + requires PaymentRequest provides PaymentResult } component InventoryManager { title = "Inventory Manager" - requires InventoryCheck via inventory + requires InventoryCheck provides InventoryStatus } + + connect PaymentGateway.PaymentRequest -> $payment -> OrderService.PaymentRequest { + protocol = "HTTP" + async = true + } + connect InventoryManager.InventoryCheck -> $inventory -> OrderService.InventoryCheck { + protocol = "HTTP" + } } """ result = _parse(source) @@ -1480,54 +1598,57 @@ def test_complete_spec_example_ecommerce_system(self) -> None: ecommerce = next(s for s in result.systems if s.name == "ECommerce") assert ecommerce.title == "E-Commerce Platform" assert len(ecommerce.components) == 3 # use + 2 inline - assert len(ecommerce.channels) == 2 + assert len(ecommerce.connects) == 2 gw = next(c for c in ecommerce.components if c.name == "PaymentGateway") assert gw.tags == ["critical", "pci-scope"] - payment_ch = ecommerce.channels[0] - assert payment_ch.protocol == "HTTP" - assert payment_ch.is_async is True + payment_conn = ecommerce.connects[0] + assert payment_conn.protocol == "HTTP" + assert payment_conn.is_async is True - def test_nested_component_with_channels(self) -> None: - """Parse a component with nested sub-components and internal channel.""" + def test_nested_component_with_connects(self) -> None: + """Parse a component with nested sub-components and internal connect.""" source = """\ component OrderService { title = "Order Service" - channel validation: ValidationResult - component Validator { title = "Order Validator" requires OrderRequest - provides ValidationResult via validation + provides ValidationResult } component Processor { title = "Order Processor" - requires ValidationResult via validation + requires ValidationResult requires PaymentRequest requires InventoryCheck provides OrderConfirmation } + + connect Validator.ValidationResult -> $validation -> Processor.ValidationResult } """ result = _parse(source) comp = result.components[0] assert comp.name == "OrderService" assert len(comp.components) == 2 - assert len(comp.channels) == 1 + assert len(comp.connects) == 1 validator = comp.components[0] assert validator.name == "Validator" assert validator.requires[0].name == "OrderRequest" assert validator.provides[0].name == "ValidationResult" - assert validator.provides[0].via == "validation" - processor = comp.components[1] - assert processor.requires[0].via == "validation" + conn = comp.connects[0] + assert conn.src_entity == "Validator" + assert conn.src_port == "ValidationResult" + assert conn.channel == "validation" + assert conn.dst_entity == "Processor" + assert conn.dst_port == "ValidationResult" def test_enterprise_nested_systems(self) -> None: """Parse an enterprise system with nested sub-systems.""" @@ -1535,37 +1656,35 @@ def test_enterprise_nested_systems(self) -> None: system Enterprise { title = "Enterprise Landscape" - channel inventory: InventorySync - system ECommerce {} system Warehouse {} + + connect ECommerce.InventorySync -> $inventory -> Warehouse.InventorySync } """ result = _parse(source) enterprise = result.systems[0] assert enterprise.title == "Enterprise Landscape" assert len(enterprise.systems) == 2 - assert len(enterprise.channels) == 1 - assert enterprise.channels[0].interface.name == "InventorySync" + assert len(enterprise.connects) == 1 + assert enterprise.connects[0].channel == "inventory" - def test_multiple_via_bindings(self) -> None: - """Components can bind to multiple channels independently.""" + def test_multiple_port_aliases(self) -> None: + """Components can declare requires/provides with explicit port aliases using 'as'.""" source = """\ system S { - channel payment: PaymentRequest - channel inventory: InventoryCheck component OrderService { - requires PaymentRequest via payment - requires InventoryCheck via inventory + requires PaymentRequest as pay_in + requires InventoryCheck as inv_in provides OrderConfirmation } } """ result = _parse(source) comp = result.systems[0].components[0] - assert comp.requires[0].via == "payment" - assert comp.requires[1].via == "inventory" - assert comp.provides[0].via is None + assert comp.requires[0].port_name == "pay_in" + assert comp.requires[1].port_name == "inv_in" + assert comp.provides[0].port_name is None # ############### @@ -1707,10 +1826,10 @@ def test_import_missing_import_keyword(self) -> None: with pytest.raises(ParseError): _parse("from interfaces/order X") - def test_unknown_channel_attribute(self) -> None: + def test_unknown_connect_attribute(self) -> None: with pytest.raises(ParseError) as exc_info: - _parse("system S {\n channel payment: PaymentRequest {\n timeout = 30\n }\n}") - assert "Unknown channel attribute" in str(exc_info.value) + _parse("system S {\n connect A.p -> $ch -> B.p {\n timeout = 30\n }\n}") + assert "Unknown connect attribute" in str(exc_info.value) def test_unknown_field_annotation(self) -> None: with pytest.raises(ParseError): @@ -1754,10 +1873,17 @@ def test_interface_version_with_alphanumeric(self) -> None: result = _parse("interface X @v10 {}") assert result.interfaces[0].version == "v10" - def test_channel_interface_version(self) -> None: - result = _parse("system S { channel feed: DataFeed @v2 }") - ch = result.systems[0].channels[0] - assert ch.interface.version == "v2" + def test_connect_with_versioned_interface(self) -> None: + source = ( + "system S { component A { provides DataFeed } " + "component B { requires DataFeed } " + "connect A.DataFeed -> $feed -> B.DataFeed }" + ) + result = _parse(source) + conn = result.systems[0].connects[0] + assert conn.src_entity == "A" + assert conn.channel == "feed" + assert conn.dst_entity == "B" def test_requires_with_version(self) -> None: result = _parse("component X { requires Interface @v2 }") @@ -1925,12 +2051,17 @@ def test_map_with_named_key_and_value(self) -> None: assert isinstance(map_type.value_type, NamedTypeRef) assert map_type.value_type.name == "OrderItem" - def test_channel_without_annotation_block(self) -> None: - result = _parse("system S { channel payment: PaymentRequest }") - ch = result.systems[0].channels[0] - assert ch.protocol is None - assert ch.is_async is False - assert ch.description is None + def test_connect_without_annotation_block(self) -> None: + source = ( + "system S { component A { provides PaymentRequest } " + "component B { requires PaymentRequest } " + "connect A.PaymentRequest -> $payment -> B.PaymentRequest }" + ) + result = _parse(source) + conn = result.systems[0].connects[0] + assert conn.protocol is None + assert conn.is_async is False + assert conn.description is None def test_interface_field_empty_annotation_block(self) -> None: source = "interface I { field x: String {} }" diff --git a/tests/compiler/test_scanner.py b/tests/compiler/test_scanner.py index 8fd04c7..b2f877f 100644 --- a/tests/compiler/test_scanner.py +++ b/tests/compiler/test_scanner.py @@ -82,8 +82,9 @@ class TestKeywords: ("schema", TokenType.SCHEMA), ("requires", TokenType.REQUIRES), ("provides", TokenType.PROVIDES), - ("channel", TokenType.CHANNEL), - ("via", TokenType.VIA), + ("connect", TokenType.CONNECT), + ("expose", TokenType.EXPOSE), + ("as", TokenType.AS), ("from", TokenType.FROM), ("import", TokenType.IMPORT), ("use", TokenType.USE), @@ -564,12 +565,12 @@ def test_token_after_multiline_block_comment(self) -> None: assert tokens[0].line == 3 assert tokens[0].column == 4 - def test_via_keyword_position(self) -> None: - tokens = _tokens_no_eof("requires X via ch") - via_tok = tokens[2] - assert via_tok.type == TokenType.VIA - assert via_tok.line == 1 - assert via_tok.column == 12 + def test_as_keyword_position(self) -> None: + tokens = _tokens_no_eof("requires X as pay_in") + as_tok = tokens[2] + assert as_tok.type == TokenType.AS + assert as_tok.line == 1 + assert as_tok.column == 12 def test_string_start_position_is_at_opening_quote(self) -> None: tokens = _tokens_no_eof('x = "hello"') @@ -608,9 +609,12 @@ def test_unexpected_character_semicolon(self) -> None: with pytest.raises(LexerError): tokenize(";") - def test_unexpected_character_dollar(self) -> None: - with pytest.raises(LexerError): - tokenize("$") + def test_dollar_sign_produces_dollar_token(self) -> None: + tokens = _tokens_no_eof("$payment") + assert tokens[0].type == TokenType.DOLLAR + assert tokens[0].value == "$" + assert tokens[1].type == TokenType.IDENTIFIER + assert tokens[1].value == "payment" def test_unexpected_character_tilde(self) -> None: with pytest.raises(LexerError): @@ -725,26 +729,50 @@ def test_optional_type(self) -> None: TokenType.RANGLE, ] - def test_channel_declaration(self) -> None: - source = "channel payment: PaymentRequest" + def test_connect_statement(self) -> None: + source = "connect A.PaymentRequest -> $payment -> B.PaymentRequest" types = _types(source) assert types == [ - TokenType.CHANNEL, + TokenType.CONNECT, TokenType.IDENTIFIER, - TokenType.COLON, + TokenType.DOT, + TokenType.IDENTIFIER, + TokenType.ARROW, + TokenType.DOLLAR, + TokenType.IDENTIFIER, + TokenType.ARROW, + TokenType.IDENTIFIER, + TokenType.DOT, + TokenType.IDENTIFIER, + ] + + def test_expose_statement(self) -> None: + source = "expose Processor.OrderConfirmation as confirmed" + types = _types(source) + assert types == [ + TokenType.EXPOSE, + TokenType.IDENTIFIER, + TokenType.DOT, + TokenType.IDENTIFIER, + TokenType.AS, TokenType.IDENTIFIER, ] - def test_requires_via_declaration(self) -> None: - source = "requires PaymentRequest via payment" + def test_requires_as_declaration(self) -> None: + source = "requires PaymentRequest as pay_in" types = _types(source) assert types == [ TokenType.REQUIRES, TokenType.IDENTIFIER, - TokenType.VIA, + TokenType.AS, TokenType.IDENTIFIER, ] + def test_arrow_token(self) -> None: + tokens = _tokens_no_eof("->") + assert tokens[0].type == TokenType.ARROW + assert tokens[0].value == "->" + def test_import_statement(self) -> None: source = "from interfaces/order import OrderRequest, OrderConfirmation" types = _types(source) @@ -834,18 +862,25 @@ def test_schema_annotation(self) -> None: types = _types(source) assert types == [TokenType.SCHEMA, TokenType.EQUALS, TokenType.STRING] - def test_channel_with_block_annotation(self) -> None: + def test_connect_with_block_annotation(self) -> None: source = """ - channel payment: PaymentRequest { + connect A.PaymentRequest -> $payment -> B.PaymentRequest { protocol = "HTTP" async = true } """ types = _types(source) assert types == [ - TokenType.CHANNEL, + TokenType.CONNECT, TokenType.IDENTIFIER, - TokenType.COLON, + TokenType.DOT, + TokenType.IDENTIFIER, + TokenType.ARROW, + TokenType.DOLLAR, + TokenType.IDENTIFIER, + TokenType.ARROW, + TokenType.IDENTIFIER, + TokenType.DOT, TokenType.IDENTIFIER, TokenType.LBRACE, TokenType.IDENTIFIER, @@ -946,26 +981,31 @@ def test_interface_block(self) -> None: assert "Order Creation Request" in string_values assert "Payload for submitting a new customer order." in string_values - def test_system_with_channels(self) -> None: + def test_system_with_connect(self) -> None: source = """ system ECommerce { title = "E-Commerce Platform" - channel payment: PaymentRequest - component PaymentGateway { tags = ["critical", "pci-scope"] - requires PaymentRequest via payment - provides PaymentResult + provides PaymentRequest } + + component OrderService { + requires PaymentRequest + } + + connect PaymentGateway.PaymentRequest -> $payment -> OrderService.PaymentRequest } """ tokens = _tokens_no_eof(source) assert tokens[0].type == TokenType.SYSTEM - channel_tokens = [t for t in tokens if t.type == TokenType.CHANNEL] - via_tokens = [t for t in tokens if t.type == TokenType.VIA] - assert len(channel_tokens) == 1 - assert len(via_tokens) == 1 + connect_tokens = [t for t in tokens if t.type == TokenType.CONNECT] + arrow_tokens = [t for t in tokens if t.type == TokenType.ARROW] + dollar_tokens = [t for t in tokens if t.type == TokenType.DOLLAR] + assert len(connect_tokens) == 1 + assert len(arrow_tokens) == 2 + assert len(dollar_tokens) == 1 def test_file_field_annotation(self) -> None: source = """ diff --git a/tests/compiler/test_semantic_analysis.py b/tests/compiler/test_semantic_analysis.py index a402c8a..34906ea 100644 --- a/tests/compiler/test_semantic_analysis.py +++ b/tests/compiler/test_semantic_analysis.py @@ -7,8 +7,8 @@ from archml.compiler.semantic_analysis import SemanticError, analyze from archml.model.entities import ( ArchFile, - ChannelDef, Component, + ConnectDef, EnumDef, InterfaceDef, InterfaceRef, @@ -113,41 +113,41 @@ def test_interface_with_known_type_field(self) -> None: } """) - def test_system_with_components_and_channel(self) -> None: + def test_system_with_components_and_connect(self) -> None: _assert_clean(""" interface DataFeed { field payload: String } system Pipeline { - channel feed: DataFeed - component Producer { - provides DataFeed via feed + provides DataFeed } component Consumer { - requires DataFeed via feed + requires DataFeed } + + connect Producer.DataFeed -> $feed -> Consumer.DataFeed } """) - def test_nested_components_with_channel(self) -> None: + def test_nested_components_with_connect(self) -> None: _assert_clean(""" interface Signal { field value: Bool } component Router { - channel sig: Signal - component Input { - provides Signal via sig + provides Signal } component Output { - requires Signal via sig + requires Signal } + + connect Input.Signal -> $sig -> Output.Signal } """) @@ -678,116 +678,79 @@ def test_system_with_undefined_provides(self) -> None: # ############### -class TestChannelValidation: - def test_valid_system_channel(self) -> None: +class TestConnectValidation: + def test_valid_system_connect(self) -> None: _assert_clean(""" interface DataFeed { field payload: String } system Pipeline { - channel feed: DataFeed - component Producer { provides DataFeed via feed } - component Consumer { requires DataFeed via feed } + component Producer { provides DataFeed } + component Consumer { requires DataFeed } + connect Producer.DataFeed -> $feed -> Consumer.DataFeed } """) - def test_valid_component_channel(self) -> None: + def test_valid_component_connect(self) -> None: _assert_clean(""" interface Signal { field value: Int } component Processor { - channel sig: Signal - component Source { provides Signal via sig } - component Sink { requires Signal via sig } + component Source { provides Signal } + component Sink { requires Signal } + connect Source.Signal -> $sig -> Sink.Signal } """) - def test_channel_with_undefined_interface(self) -> None: + def test_connect_with_unknown_src_entity(self) -> None: _assert_error( """ +interface DataFeed { field payload: String } system Pipeline { - channel feed: UndefinedInterface + component Consumer { requires DataFeed } + connect Ghost.DataFeed -> Consumer.DataFeed } """, - "refers to unknown interface 'UndefinedInterface'", + "connect references unknown child entity 'Ghost'", ) - def test_via_binding_to_unknown_channel(self) -> None: + def test_connect_with_unknown_dst_entity(self) -> None: _assert_error( """ interface DataFeed { field payload: String } system Pipeline { - component Producer { provides DataFeed via nonexistent } + component Producer { provides DataFeed } + connect Producer.DataFeed -> Ghost.DataFeed } """, - "channel 'nonexistent' is not defined in this scope", + "connect references unknown child entity 'Ghost'", ) - def test_via_binding_in_component_to_unknown_channel(self) -> None: + def test_expose_with_unknown_entity(self) -> None: _assert_error( """ interface Signal { field v: Bool } component Router { - component Output { requires Signal via unknown_channel } + component Input { provides Signal } + expose Missing.Signal } """, - "channel 'unknown_channel' is not defined in this scope", + "expose references unknown child entity 'Missing'", ) - def test_valid_via_binding_to_parent_system_channel(self) -> None: + def test_valid_expose(self) -> None: _assert_clean(""" -interface DataFeed { field payload: String } -system Pipeline { - channel feed: DataFeed - component Producer { provides DataFeed via feed } - component Consumer { requires DataFeed via feed } +interface Signal { field value: Bool } +component Router { + component Input { provides Signal } + expose Input.Signal } """) - def test_channel_with_versioned_interface_ok(self) -> None: - _assert_clean(""" -interface Feed @v1 { field data: String } -system Pipeline { - channel feed: Feed @v1 - component A { provides Feed @v1 via feed } - component B { requires Feed @v1 via feed } -} -""") - - def test_duplicate_channel_names_in_system(self) -> None: - _assert_error( - """ -interface X {} -system S { - channel ch: X - channel ch: X -} -""", - "Duplicate channel name 'ch'", - ) - - def test_duplicate_channel_names_in_component(self) -> None: - _assert_error( - """ -interface X {} -component C { - channel ch: X - channel ch: X -} -""", - "Duplicate channel name 'ch'", - ) - - def test_requires_without_via_is_valid(self) -> None: + def test_direct_connect_no_channel(self) -> None: _assert_clean(""" interface DataFeed { field payload: String } -component Producer { provides DataFeed } -""") - - def test_channel_in_component_scope(self) -> None: - _assert_clean(""" -interface Signal { field value: Bool } -component Router { - channel sig: Signal - component Input { provides Signal via sig } - component Output { requires Signal via sig } +system Pipeline { + component Producer { provides DataFeed } + component Consumer { requires DataFeed } + connect Producer.DataFeed -> Consumer.DataFeed } """) @@ -953,16 +916,24 @@ def test_duplicate_enum_names_in_model(self) -> None: errors = analyze(arch_file) assert any("Duplicate enum name 'Status'" in e.message for e in errors) - def test_channel_with_known_interface_model(self) -> None: + def test_connect_with_known_interface_model(self) -> None: arch_file = ArchFile( interfaces=[InterfaceDef(name="Signal", version=None)], systems=[ System( name="Sys", - channels=[ChannelDef(name="sig", interface=InterfaceRef(name="Signal"))], + connects=[ + ConnectDef( + src_entity="A", + src_port="Signal", + channel="sig", + dst_entity="B", + dst_port="Signal", + ) + ], components=[ - Component(name="A", provides=[InterfaceRef(name="Signal", via="sig")]), - Component(name="B", requires=[InterfaceRef(name="Signal", via="sig")]), + Component(name="A", provides=[InterfaceRef(name="Signal")]), + Component(name="B", requires=[InterfaceRef(name="Signal")]), ], ) ], @@ -1312,13 +1283,13 @@ def test_user_name_conflicts_with_system_in_system(self) -> None: "name 'Sub' is used for both a user and a component or sub-system", ) - def test_user_provides_with_via_in_system(self) -> None: + def test_user_provides_connected_in_system(self) -> None: _assert_clean(""" interface OrderRequest {} system S { - channel orders: OrderRequest - user Customer { provides OrderRequest via orders } - component OrderService { requires OrderRequest via orders } + user Customer { provides OrderRequest } + component OrderService { requires OrderRequest } + connect Customer.OrderRequest -> $orders -> OrderService.OrderRequest } """) diff --git a/tests/data/negative/undefined_connection_endpoint.archml b/tests/data/negative/undefined_connection_endpoint.archml index 769d891..091b062 100644 --- a/tests/data/negative/undefined_connection_endpoint.archml +++ b/tests/data/negative/undefined_connection_endpoint.archml @@ -1,7 +1,7 @@ -// Negative: via references point to non-existent channels +// Negative: connect and expose statements reference non-existent child entities // Expected errors: -// component 'Processor': via binding 'ghost_ch' refers to unknown channel -// component 'Worker': via binding 'missing_ch' refers to unknown channel +// system 'Pipeline': connect references unknown child entity 'Ghost' +// component 'Router': expose references unknown child entity 'Missing' interface DataFeed { field payload: String @@ -12,17 +12,17 @@ interface Signal { } system Pipeline { - channel data_in: DataFeed - component Consumer { - requires DataFeed via ghost_ch + requires DataFeed } + + connect Ghost.DataFeed -> Consumer.DataFeed } component Router { - channel signal_ch: Signal - component Input { - provides Signal via missing_ch + provides Signal } + + expose Missing.Signal } diff --git a/tests/data/positive/compiler/system.archml b/tests/data/positive/compiler/system.archml index 4e1f26f..0ce5e00 100644 --- a/tests/data/positive/compiler/system.archml +++ b/tests/data/positive/compiler/system.archml @@ -6,10 +6,11 @@ from compiler/worker import TaskWorker, PriorityRouter system TaskProcessingSystem { use component TaskWorker use component PriorityRouter - channel task_req: TaskRequest component Dispatcher { requires TaskRequest - provides TaskRequest via task_req + provides TaskRequest } + + connect Dispatcher.TaskRequest -> $task_req -> TaskWorker.TaskRequest } diff --git a/tests/data/positive/imports/ecommerce_system.archml b/tests/data/positive/imports/ecommerce_system.archml index 77df156..bb33406 100644 --- a/tests/data/positive/imports/ecommerce_system.archml +++ b/tests/data/positive/imports/ecommerce_system.archml @@ -22,18 +22,18 @@ system ECommerce { use component OrderService - channel payment: PaymentRequest - channel stripe: PaymentRequest { - protocol = "HTTP" - async = true - } - component PaymentGateway { title = "Payment Gateway" tags = ["critical", "pci-scope"] - requires PaymentRequest via payment + requires PaymentRequest as pay_in provides PaymentResult - requires PaymentRequest via stripe + requires PaymentRequest as stripe_in + } + + connect PaymentGateway.pay_in -> $payment -> OrderService.PaymentRequest + connect PaymentGateway.stripe_in -> $stripe { + protocol = "HTTP" + async = true } } diff --git a/tests/data/positive/nested_components.archml b/tests/data/positive/nested_components.archml index fdc9f67..f20546d 100644 --- a/tests/data/positive/nested_components.archml +++ b/tests/data/positive/nested_components.archml @@ -27,26 +27,26 @@ component OrderService { description = "Accepts, validates, and processes customer orders." tags = ["core", "stateful"] - requires OrderRequest - requires PaymentRequest - provides OrderConfirmation - - channel validation: ValidationResult - component Validator { title = "Order Validator" description = "Validates incoming order requests." requires OrderRequest - provides ValidationResult via validation + provides ValidationResult } component Processor { title = "Order Processor" description = "Processes validated orders and triggers payment." - requires ValidationResult via validation + requires ValidationResult requires PaymentRequest provides OrderConfirmation } + + connect Validator.ValidationResult -> $validation -> Processor.ValidationResult + + expose Validator.OrderRequest + expose Processor.PaymentRequest + expose Processor.OrderConfirmation } diff --git a/tests/data/positive/system_with_connections.archml b/tests/data/positive/system_with_connections.archml index 41e88af..e10bcc6 100644 --- a/tests/data/positive/system_with_connections.archml +++ b/tests/data/positive/system_with_connections.archml @@ -1,4 +1,4 @@ -// Full system with nested components, external entity, and connections +// Full system with nested components, external entity, and connect statements // Expected result: no semantic errors interface PaymentRequest { @@ -46,28 +46,13 @@ system ECommerce { title = "E-Commerce Platform" description = "Customer-facing online store back-end." - channel payment: PaymentRequest { - protocol = "gRPC" - async = false - description = "Submit payment for confirmed order." - } - channel inventory: InventoryCheck { - protocol = "HTTP" - async = true - } - channel stripe_payment: PaymentRequest { - protocol = "HTTP" - async = true - description = "Delegate payment processing to Stripe." - } - component OrderService { title = "Order Service" tags = ["core"] requires OrderRequest - requires PaymentRequest via payment - requires InventoryCheck via inventory + requires PaymentRequest + requires InventoryCheck provides OrderConfirmation } @@ -75,15 +60,27 @@ system ECommerce { title = "Payment Gateway" tags = ["critical", "pci-scope"] - provides PaymentRequest via payment - requires PaymentRequest via stripe_payment - provides PaymentResult + provides PaymentRequest + requires PaymentResult } component InventoryManager { title = "Inventory Manager" - provides InventoryCheck via inventory + provides InventoryCheck provides InventoryStatus } + + connect PaymentGateway.PaymentRequest -> $payment -> OrderService.PaymentRequest { + protocol = "gRPC" + async = false + description = "Submit payment for confirmed order." + } + connect InventoryManager.InventoryCheck -> $inventory -> OrderService.InventoryCheck { + protocol = "HTTP" + async = true + } + + expose OrderService.OrderRequest + expose OrderService.OrderConfirmation } diff --git a/tests/model/test_model.py b/tests/model/test_model.py index c921503..712c698 100644 --- a/tests/model/test_model.py +++ b/tests/model/test_model.py @@ -5,8 +5,8 @@ from archml.model import ( ArchFile, - ChannelDef, Component, + ConnectDef, DirectoryTypeRef, EnumDef, FieldDef, @@ -184,71 +184,92 @@ def test_external_component() -> None: assert ext.is_external -def test_channel_definition() -> None: - """A ChannelDef is a named conduit carrying a specific interface.""" - ch = ChannelDef( - name="payment", - interface=InterfaceRef(name="PaymentRequest"), +def test_connect_definition() -> None: + """A ConnectDef wires two ports, optionally through a named channel.""" + conn = ConnectDef( + src_entity="PaymentGateway", + src_port="PaymentRequest", + channel="payment", + dst_entity="OrderService", + dst_port="PaymentRequest", protocol="gRPC", is_async=True, description="Initiates payment processing.", ) - assert ch.name == "payment" - assert ch.interface.name == "PaymentRequest" - assert ch.protocol == "gRPC" - assert ch.is_async + assert conn.src_entity == "PaymentGateway" + assert conn.src_port == "PaymentRequest" + assert conn.channel == "payment" + assert conn.dst_entity == "OrderService" + assert conn.protocol == "gRPC" + assert conn.is_async -def test_interface_ref_with_via() -> None: - """An InterfaceRef can carry an optional via binding to a named channel.""" - ref = InterfaceRef(name="PaymentRequest", via="payment") +def test_interface_ref_with_port_name() -> None: + """An InterfaceRef can carry an explicit port alias assigned with 'as'.""" + ref = InterfaceRef(name="PaymentRequest", port_name="pay_in") assert ref.name == "PaymentRequest" - assert ref.via == "payment" + assert ref.port_name == "pay_in" def test_nested_component() -> None: - """A Component can contain sub-components connected through channels.""" + """A Component can contain sub-components connected through connect statements.""" validator = Component( name="Validator", requires=[InterfaceRef(name="OrderRequest")], - provides=[InterfaceRef(name="ValidationResult", via="validation")], + provides=[InterfaceRef(name="ValidationResult")], ) processor = Component( name="Processor", - requires=[InterfaceRef(name="ValidationResult", via="validation"), InterfaceRef(name="PaymentRequest")], + requires=[InterfaceRef(name="ValidationResult"), InterfaceRef(name="PaymentRequest")], provides=[InterfaceRef(name="OrderConfirmation")], ) order_svc = Component( name="OrderService", - channels=[ChannelDef(name="validation", interface=InterfaceRef(name="ValidationResult"))], + connects=[ + ConnectDef( + src_entity="Validator", + src_port="ValidationResult", + channel="validation", + dst_entity="Processor", + dst_port="ValidationResult", + ) + ], components=[validator, processor], ) assert len(order_svc.components) == 2 - assert len(order_svc.channels) == 1 + assert len(order_svc.connects) == 1 assert order_svc.components[0].name == "Validator" -def test_system_with_components_and_channels() -> None: - """A System groups components connected through named channels.""" +def test_system_with_components_and_connects() -> None: + """A System groups components wired through connect statements.""" order_svc = Component( name="OrderService", - requires=[InterfaceRef(name="PaymentRequest", via="payment"), InterfaceRef(name="InventoryCheck")], + requires=[InterfaceRef(name="PaymentRequest"), InterfaceRef(name="InventoryCheck")], provides=[InterfaceRef(name="OrderConfirmation")], ) payment_gw = Component( name="PaymentGateway", tags=["critical", "pci-scope"], - provides=[InterfaceRef(name="PaymentRequest", via="payment")], + provides=[InterfaceRef(name="PaymentRequest")], ) ecommerce = System( name="ECommerce", title="E-Commerce Platform", - channels=[ChannelDef(name="payment", interface=InterfaceRef(name="PaymentRequest"))], + connects=[ + ConnectDef( + src_entity="PaymentGateway", + src_port="PaymentRequest", + channel="payment", + dst_entity="OrderService", + dst_port="PaymentRequest", + ) + ], components=[order_svc, payment_gw], ) assert ecommerce.name == "ECommerce" assert len(ecommerce.components) == 2 - assert len(ecommerce.channels) == 1 + assert len(ecommerce.connects) == 1 assert not ecommerce.is_external diff --git a/tests/validation/test_checks.py b/tests/validation/test_checks.py index 674e25f..3266752 100644 --- a/tests/validation/test_checks.py +++ b/tests/validation/test_checks.py @@ -5,13 +5,11 @@ from archml.model.entities import ( ArchFile, - ChannelDef, Component, InterfaceDef, InterfaceRef, System, TypeDef, - UserDef, ) from archml.model.types import ( FieldDef, @@ -32,14 +30,9 @@ # ############### -def _iref(name: str, version: str | None = None, via: str | None = None) -> InterfaceRef: +def _iref(name: str, version: str | None = None) -> InterfaceRef: """Create an InterfaceRef.""" - return InterfaceRef(name=name, version=version, via=via) - - -def _channel(name: str, interface: str, version: str | None = None) -> ChannelDef: - """Create a ChannelDef.""" - return ChannelDef(name=name, interface=InterfaceRef(name=interface, version=version)) + return InterfaceRef(name=name, version=version) def _pfield(name: str) -> FieldDef: @@ -431,144 +424,3 @@ def test_empty_archfile_is_fully_valid(self) -> None: result = validate(ArchFile()) assert result == ValidationResult() assert not result.has_errors - - -# ############### -# Unused Channels -# ############### - - -class TestUnusedChannels: - """Check: Channels declared in a scope must be bound by at least one member.""" - - # ---- No warning cases ---- - - def test_empty_archfile_no_warning(self) -> None: - _assert_no_warning(ArchFile()) - - def test_leaf_component_no_warning(self) -> None: - # A component with channels but no sub-components is a leaf — not checked. - arch = ArchFile( - components=[ - Component( - name="C", - channels=[_channel("ch", "IFace")], - requires=[_iref("IFace")], - ) - ] - ) - _assert_no_warning(arch) - - def test_leaf_system_no_warning(self) -> None: - # A system with channels but no members is a leaf — not checked. - arch = ArchFile(systems=[System(name="S", channels=[_channel("ch", "I")])]) - _assert_no_warning(arch) - - def test_channel_bound_by_requires_no_warning(self) -> None: - comp = Component(name="C", requires=[_iref("I", via="ch")]) - sys_ = System(name="S", channels=[_channel("ch", "I")], components=[comp]) - arch = ArchFile(systems=[sys_]) - _assert_no_warning(arch) - - def test_channel_bound_by_provides_no_warning(self) -> None: - comp = Component(name="C", provides=[_iref("I", via="ch")]) - sys_ = System(name="S", channels=[_channel("ch", "I")], components=[comp]) - arch = ArchFile(systems=[sys_]) - _assert_no_warning(arch) - - def test_channel_bound_by_user_no_warning(self) -> None: - user = UserDef(name="Customer", provides=[_iref("OrderRequest", via="order_in")]) - sys_ = System(name="S", channels=[_channel("order_in", "OrderRequest")], users=[user]) - arch = ArchFile(systems=[sys_]) - _assert_no_warning(arch) - - def test_multiple_channels_all_bound_no_warning(self) -> None: - c1 = Component(name="C1", requires=[_iref("X", via="ch1")]) - c2 = Component(name="C2", provides=[_iref("Y", via="ch2")]) - sys_ = System( - name="S", - channels=[_channel("ch1", "X"), _channel("ch2", "Y")], - components=[c1, c2], - ) - arch = ArchFile(systems=[sys_]) - _assert_no_warning(arch) - - def test_component_channel_bound_by_subcomponent_no_warning(self) -> None: - sub = Component(name="Sub", requires=[_iref("I", via="inner")]) - outer = Component( - name="Outer", - channels=[_channel("inner", "I")], - components=[sub], - ) - arch = ArchFile(components=[outer]) - _assert_no_warning(arch) - - # ---- Warning cases ---- - - def test_unbound_channel_in_system_warns(self) -> None: - comp = Component(name="C", requires=[_iref("I")]) # no via binding - sys_ = System(name="S", channels=[_channel("ch", "I")], components=[comp]) - arch = ArchFile(systems=[sys_]) - result = validate(arch) - msgs = _warnings(result) - assert any("'ch'" in m and "'S'" in m for m in msgs) - - def test_warning_message_mentions_channel_and_scope(self) -> None: - comp = Component(name="C") - sys_ = System(name="MySystem", channels=[_channel("payment", "PaymentRequest")], components=[comp]) - arch = ArchFile(systems=[sys_]) - result = validate(arch) - msgs = _warnings(result) - assert any("'payment'" in m and "'MySystem'" in m for m in msgs) - - def test_partially_bound_channel_second_unbound_warns(self) -> None: - # ch1 is bound, ch2 is not. - c1 = Component(name="C1", requires=[_iref("X", via="ch1")]) - sys_ = System( - name="S", - channels=[_channel("ch1", "X"), _channel("ch2", "Y")], - components=[c1], - ) - arch = ArchFile(systems=[sys_]) - result = validate(arch) - msgs = _warnings(result) - assert any("'ch2'" in m for m in msgs) - assert not any("'ch1'" in m for m in msgs) - - def test_unbound_channel_in_component_scope_warns(self) -> None: - sub = Component(name="Sub", requires=[_iref("I")]) # no via - outer = Component( - name="Outer", - channels=[_channel("inner", "I")], - components=[sub], - ) - arch = ArchFile(components=[outer]) - result = validate(arch) - msgs = _warnings(result) - assert any("'inner'" in m and "'Outer'" in m for m in msgs) - - def test_nested_system_channels_checked_recursively(self) -> None: - inner_comp = Component(name="IC") # no via binding - inner = System( - name="Inner", - channels=[_channel("ch", "I")], - components=[inner_comp], - ) - outer = System(name="Outer", systems=[inner]) - arch = ArchFile(systems=[outer]) - result = validate(arch) - msgs = _warnings(result) - assert any("'ch'" in m and "'Inner'" in m for m in msgs) - - def test_qualified_name_used_in_warning_when_set(self) -> None: - comp = Component(name="C") - sys_ = System( - name="S", - qualified_name="Root::S", - channels=[_channel("ch", "I")], - components=[comp], - ) - arch = ArchFile(systems=[sys_]) - result = validate(arch) - msgs = _warnings(result) - assert any("'Root::S'" in m for m in msgs) diff --git a/tests/views/backend/test_diagram.py b/tests/views/backend/test_diagram.py index d653242..a16d6a0 100644 --- a/tests/views/backend/test_diagram.py +++ b/tests/views/backend/test_diagram.py @@ -8,7 +8,7 @@ import pytest -from archml.model.entities import ChannelDef, Component, InterfaceRef, System +from archml.model.entities import Component, ConnectDef, InterfaceRef, System from archml.views.backend.diagram import render_diagram from archml.views.placement import compute_layout from archml.views.topology import build_viz_diagram @@ -20,8 +20,14 @@ _SVG_NS = "http://www.w3.org/2000/svg" -def _iref(name: str, version: str | None = None, via: str | None = None) -> InterfaceRef: - return InterfaceRef(name=name, version=version, via=via) +def _iref(name: str, version: str | None = None) -> InterfaceRef: + return InterfaceRef(name=name, version=version) + + +def _connect(src_entity: str, src_port: str, dst_entity: str, dst_port: str, channel: str | None = None) -> ConnectDef: + return ConnectDef( + src_entity=src_entity, src_port=src_port, channel=channel, dst_entity=dst_entity, dst_port=dst_port + ) def _render_and_parse(entity: Component | System, tmp_path: Path, **kwargs: object) -> ET.Element: @@ -229,12 +235,12 @@ def test_render_versioned_terminal_label(tmp_path: Path) -> None: def test_render_edge_label_present(tmp_path: Path) -> None: - """Channel interface name appears as a text label in the SVG.""" - a = Component(name="A", requires=[_iref("PaymentRequest", via="payment")]) - b = Component(name="B", provides=[_iref("PaymentRequest", via="payment")]) + """Connect interface name appears as a text label in the SVG.""" + a = Component(name="A", requires=[_iref("PaymentRequest")]) + b = Component(name="B", provides=[_iref("PaymentRequest")]) sys = System( name="Root", - channels=[ChannelDef(name="payment", interface=InterfaceRef(name="PaymentRequest"))], + connects=[_connect("B", "PaymentRequest", "A", "PaymentRequest", channel="payment")], components=[a, b], ) root = _render_and_parse(sys, tmp_path) @@ -261,11 +267,11 @@ def test_render_clip_paths_defined_in_defs(tmp_path: Path) -> None: def test_render_edge_polyline_present(tmp_path: Path) -> None: """An edge between two children produces at least one ```` element.""" - a = Component(name="A", requires=[_iref("IFace", via="ch")]) - b = Component(name="B", provides=[_iref("IFace", via="ch")]) + a = Component(name="A", requires=[_iref("IFace")]) + b = Component(name="B", provides=[_iref("IFace")]) sys = System( name="Root", - channels=[ChannelDef(name="ch", interface=InterfaceRef(name="IFace"))], + connects=[_connect("B", "IFace", "A", "IFace", channel="ch")], components=[a, b], ) root = _render_and_parse(sys, tmp_path) @@ -275,11 +281,11 @@ def test_render_edge_polyline_present(tmp_path: Path) -> None: def test_render_edge_has_explicit_arrowhead_polygon(tmp_path: Path) -> None: """An edge produces an explicit filled ```` arrowhead in the SVG.""" - a = Component(name="A", requires=[_iref("IFace", via="ch")]) - b = Component(name="B", provides=[_iref("IFace", via="ch")]) + a = Component(name="A", requires=[_iref("IFace")]) + b = Component(name="B", provides=[_iref("IFace")]) sys = System( name="Root", - channels=[ChannelDef(name="ch", interface=InterfaceRef(name="IFace"))], + connects=[_connect("B", "IFace", "A", "IFace", channel="ch")], components=[a, b], ) root = _render_and_parse(sys, tmp_path) @@ -296,13 +302,13 @@ def test_render_edge_has_explicit_arrowhead_polygon(tmp_path: Path) -> None: def test_render_ecommerce_system(tmp_path: Path) -> None: - """Full integration: multi-component system with channel renders without error.""" + """Full integration: multi-component system with connect renders without error.""" sys = System( name="ECommerce", - channels=[ChannelDef(name="payment", interface=InterfaceRef(name="PaymentRequest"))], + connects=[_connect("PaymentService", "PaymentRequest", "OrderService", "PaymentRequest", channel="payment")], components=[ - Component(name="OrderService", requires=[_iref("PaymentRequest", via="payment")]), - Component(name="PaymentService", provides=[_iref("PaymentRequest", via="payment")]), + Component(name="OrderService", requires=[_iref("PaymentRequest")]), + Component(name="PaymentService", provides=[_iref("PaymentRequest")]), Component(name="NotificationService", requires=[_iref("OrderRequest")]), ], ) diff --git a/tests/views/test_placement.py b/tests/views/test_placement.py index 5892d3c..e50febc 100644 --- a/tests/views/test_placement.py +++ b/tests/views/test_placement.py @@ -5,7 +5,7 @@ import pytest -from archml.model.entities import ChannelDef, Component, InterfaceRef, System +from archml.model.entities import Component, ConnectDef, InterfaceRef, System from archml.views.placement import ( LayoutConfig, LayoutPlan, @@ -25,8 +25,28 @@ # ############### -def _iref(name: str, version: str | None = None, via: str | None = None) -> InterfaceRef: - return InterfaceRef(name=name, version=version, via=via) +def _iref(name: str, version: str | None = None) -> InterfaceRef: + return InterfaceRef(name=name, version=version) + + +def _connect( + src_entity: str, + src_port: str, + dst_entity: str, + dst_port: str, + channel: str | None = None, + protocol: str | None = None, + is_async: bool = False, +) -> ConnectDef: + return ConnectDef( + src_entity=src_entity, + src_port=src_port, + channel=channel, + dst_entity=dst_entity, + dst_port=dst_port, + protocol=protocol, + is_async=is_async, + ) def _port(node_id: str, direction: str, name: str) -> VizPort: @@ -618,26 +638,26 @@ def test_peripheral_nodes_outside_boundary_in_total_width() -> None: def test_ecommerce_system_produces_complete_plan() -> None: - """Full integration: ecommerce system with multiple components connected via channels.""" + """Full integration: ecommerce system with multiple components connected via connect statements.""" sys = System( name="ECommerce", - channels=[ - ChannelDef(name="payment", interface=InterfaceRef(name="PaymentRequest")), - ChannelDef(name="notification", interface=InterfaceRef(name="OrderRequest")), + connects=[ + _connect("PaymentService", "PaymentRequest", "OrderService", "PaymentRequest", channel="payment"), + _connect("OrderService", "OrderRequest", "NotificationService", "OrderRequest", channel="notification"), ], components=[ Component( name="OrderService", - requires=[_iref("PaymentRequest", via="payment")], - provides=[_iref("OrderRequest", via="notification")], + requires=[_iref("PaymentRequest")], + provides=[_iref("OrderRequest")], ), Component( name="PaymentService", - provides=[_iref("PaymentRequest", via="payment")], + provides=[_iref("PaymentRequest")], ), Component( name="NotificationService", - requires=[_iref("OrderRequest", via="notification")], + requires=[_iref("OrderRequest")], ), ], ) @@ -655,19 +675,19 @@ def test_ecommerce_system_produces_complete_plan() -> None: assert len(plan.edge_routes) == len(diagram.edges) -def test_ecommerce_order_service_left_of_payment_service() -> None: - """OrderService (requirer) is to the left of PaymentService (provider).""" +def test_ecommerce_payment_service_left_of_order_service() -> None: + """PaymentService (provider/source) is to the left of OrderService (requirer/target).""" sys = System( name="ECommerce", - channels=[ChannelDef(name="payment", interface=InterfaceRef(name="PaymentRequest"))], + connects=[_connect("PaymentService", "PaymentRequest", "OrderService", "PaymentRequest", channel="payment")], components=[ Component( name="OrderService", - requires=[_iref("PaymentRequest", via="payment")], + requires=[_iref("PaymentRequest")], ), Component( name="PaymentService", - provides=[_iref("PaymentRequest", via="payment")], + provides=[_iref("PaymentRequest")], ), ], ) @@ -675,17 +695,17 @@ def test_ecommerce_order_service_left_of_payment_service() -> None: plan = compute_layout(diagram) order_id = next(c.id for c in diagram.root.children if c.label == "OrderService") payment_id = next(c.id for c in diagram.root.children if c.label == "PaymentService") - assert plan.nodes[order_id].x < plan.nodes[payment_id].x + assert plan.nodes[payment_id].x < plan.nodes[order_id].x def test_external_actor_in_components_positioned() -> None: """An external actor declared in components receives a layout entry.""" - stripe = Component(name="Stripe", is_external=True, provides=[_iref("PaymentGateway", via="payment")]) + stripe = Component(name="Stripe", is_external=True, provides=[_iref("PaymentGateway")]) sys = System( name="ECommerce", - channels=[ChannelDef(name="payment", interface=InterfaceRef(name="PaymentGateway"))], + connects=[_connect("Stripe", "PaymentGateway", "OrderService", "PaymentGateway", channel="payment")], components=[ - Component(name="OrderService", requires=[_iref("PaymentGateway", via="payment")]), + Component(name="OrderService", requires=[_iref("PaymentGateway")]), stripe, ], ) @@ -695,14 +715,14 @@ def test_external_actor_in_components_positioned() -> None: assert stripe_node.id in plan.nodes -def test_external_actor_right_of_requirer_when_it_provides() -> None: - """An external provider (target of edge) is placed right of the requirer.""" - stripe = Component(name="Stripe", is_external=True, provides=[_iref("PaymentGateway", via="payment")]) +def test_external_actor_left_of_requirer_when_it_provides() -> None: + """An external provider (source of edge) is placed left of the requirer (target).""" + stripe = Component(name="Stripe", is_external=True, provides=[_iref("PaymentGateway")]) sys = System( name="ECommerce", - channels=[ChannelDef(name="payment", interface=InterfaceRef(name="PaymentGateway"))], + connects=[_connect("Stripe", "PaymentGateway", "OrderService", "PaymentGateway", channel="payment")], components=[ - Component(name="OrderService", requires=[_iref("PaymentGateway", via="payment")]), + Component(name="OrderService", requires=[_iref("PaymentGateway")]), stripe, ], ) @@ -710,7 +730,7 @@ def test_external_actor_right_of_requirer_when_it_provides() -> None: plan = compute_layout(diagram) order_id = next(c.id for c in diagram.root.children if c.label == "OrderService") stripe_id = next(c.id for c in diagram.root.children if c.label == "Stripe") - assert plan.nodes[order_id].x < plan.nodes[stripe_id].x + assert plan.nodes[stripe_id].x < plan.nodes[order_id].x def test_all_ports_in_diagram_have_anchors() -> None: @@ -719,10 +739,10 @@ def test_all_ports_in_diagram_have_anchors() -> None: name="ECommerce", requires=[_iref("ClientRequest")], provides=[_iref("ClientResponse")], - channels=[ChannelDef(name="b_service", interface=InterfaceRef(name="BService"))], + connects=[_connect("B", "BService", "A", "BService", channel="b_service")], components=[ - Component(name="A", requires=[_iref("BService", via="b_service")]), - Component(name="B", provides=[_iref("BService", via="b_service")]), + Component(name="A", requires=[_iref("BService")]), + Component(name="B", provides=[_iref("BService")]), ], ) diagram = build_viz_diagram(sys) diff --git a/tests/views/test_topology.py b/tests/views/test_topology.py index 1171743..2759ab2 100644 --- a/tests/views/test_topology.py +++ b/tests/views/test_topology.py @@ -3,7 +3,7 @@ """Tests for the abstract visualization topology model and its builder.""" -from archml.model.entities import ChannelDef, Component, InterfaceRef, System, UserDef +from archml.model.entities import Component, ConnectDef, InterfaceRef, System, UserDef from archml.views.topology import ( VizBoundary, VizNode, @@ -17,21 +17,26 @@ # ############### -def _iref(name: str, version: str | None = None, via: str | None = None) -> InterfaceRef: - return InterfaceRef(name=name, version=version, via=via) +def _iref(name: str, version: str | None = None) -> InterfaceRef: + return InterfaceRef(name=name, version=version) -def _channel( - name: str, - interface: str, - version: str | None = None, +def _connect( + src_entity: str, + src_port: str, + dst_entity: str, + dst_port: str, + channel: str | None = None, protocol: str | None = None, is_async: bool = False, description: str | None = None, -) -> ChannelDef: - return ChannelDef( - name=name, - interface=InterfaceRef(name=interface, version=version), +) -> ConnectDef: + return ConnectDef( + src_entity=src_entity, + src_port=src_port, + channel=channel, + dst_entity=dst_entity, + dst_port=dst_port, protocol=protocol, is_async=is_async, description=description, @@ -332,86 +337,116 @@ def test_no_terminals_for_leaf_without_interfaces() -> None: # ############### -# Edges — channel-based +# Edges — connect-based # ############### -def test_edge_created_for_channel_binding() -> None: - """A VizEdge is created for each channel with a provider-requirer pair.""" - a = Component(name="A", requires=[_iref("IFace", via="ch")]) - b = Component(name="B", provides=[_iref("IFace", via="ch")]) - parent = System(name="S", channels=[_channel("ch", "IFace")], components=[a, b]) +def test_edge_created_for_connect_statement() -> None: + """A VizEdge is created for each full connect statement.""" + a = Component(name="A", requires=[_iref("IFace")]) + b = Component(name="B", provides=[_iref("IFace")]) + parent = System( + name="S", + connects=[_connect("B", "IFace", "A", "IFace", channel="ch")], + components=[a, b], + ) diag = build_viz_diagram(parent) assert len(diag.edges) == 1 -def test_channel_with_no_binders_creates_no_edges() -> None: - """A channel that no sub-entity binds to produces no edges.""" - a = Component(name="A", requires=[_iref("IFace")]) # no via - parent = System(name="S", channels=[_channel("ch", "IFace")], components=[a]) +def test_one_sided_connect_creates_no_edge() -> None: + """A one-sided connect (no dst) produces no edge.""" + a = Component(name="A", provides=[_iref("IFace")]) + parent = System( + name="S", + connects=[ConnectDef(src_entity="A", src_port="IFace", channel="ch")], + components=[a], + ) diag = build_viz_diagram(parent) assert len(diag.edges) == 0 -def test_edge_label_is_channel_interface_name() -> None: - a = Component(name="A", requires=[_iref("PayReq", via="pay")]) - b = Component(name="B", provides=[_iref("PayReq", via="pay")]) - parent = System(name="S", channels=[_channel("pay", "PayReq")], components=[a, b]) +def test_edge_label_is_interface_name() -> None: + a = Component(name="A", requires=[_iref("PayReq")]) + b = Component(name="B", provides=[_iref("PayReq")]) + parent = System( + name="S", + connects=[_connect("B", "PayReq", "A", "PayReq", channel="pay")], + components=[a, b], + ) diag = build_viz_diagram(parent) assert diag.edges[0].label == "PayReq" def test_edge_label_includes_version() -> None: - a = Component(name="A", requires=[_iref("API", "v2", via="ch")]) - b = Component(name="B", provides=[_iref("API", "v2", via="ch")]) - parent = System(name="S", channels=[_channel("ch", "API", version="v2")], components=[a, b]) + a = Component(name="A", requires=[_iref("API", "v2")]) + b = Component(name="B", provides=[_iref("API", "v2")]) + parent = System( + name="S", + connects=[ConnectDef(src_entity="B", src_port="API", channel="ch", dst_entity="A", dst_port="API")], + components=[a, b], + ) diag = build_viz_diagram(parent) assert diag.edges[0].label == "API@v2" -def test_edge_source_port_is_requires_port() -> None: - """Edge source_port_id references a requires port on the requirer node.""" - a = Component(name="A", requires=[_iref("IFace", via="ch")]) - b = Component(name="B", provides=[_iref("IFace", via="ch")]) - parent = System(name="S", channels=[_channel("ch", "IFace")], components=[a, b]) +def test_edge_source_port_is_src_entity_port() -> None: + """Edge source_port_id references the src_entity's port.""" + a = Component(name="A", requires=[_iref("IFace")]) + b = Component(name="B", provides=[_iref("IFace")]) + # B is src_entity (provider), A is dst_entity (requirer) + parent = System( + name="S", + connects=[_connect("B", "IFace", "A", "IFace", channel="ch")], + components=[a, b], + ) diag = build_viz_diagram(parent) all_ports = collect_all_ports(diag) src_port = all_ports[diag.edges[0].source_port_id] - assert src_port.direction == "requires" + assert src_port.direction == "provides" assert src_port.interface_name == "IFace" -def test_edge_target_port_is_provides_port() -> None: - """Edge target_port_id references a provides port on the provider node.""" - a = Component(name="A", requires=[_iref("IFace", via="ch")]) - b = Component(name="B", provides=[_iref("IFace", via="ch")]) - parent = System(name="S", channels=[_channel("ch", "IFace")], components=[a, b]) +def test_edge_target_port_is_dst_entity_port() -> None: + """Edge target_port_id references the dst_entity's port.""" + a = Component(name="A", requires=[_iref("IFace")]) + b = Component(name="B", provides=[_iref("IFace")]) + parent = System( + name="S", + connects=[_connect("B", "IFace", "A", "IFace", channel="ch")], + components=[a, b], + ) diag = build_viz_diagram(parent) all_ports = collect_all_ports(diag) tgt_port = all_ports[diag.edges[0].target_port_id] - assert tgt_port.direction == "provides" + assert tgt_port.direction == "requires" assert tgt_port.interface_name == "IFace" def test_edge_source_and_target_port_owners() -> None: - """Source port belongs to the requirer node; target port to the provider node.""" - a = Component(name="A", requires=[_iref("IFace", via="ch")]) - b = Component(name="B", provides=[_iref("IFace", via="ch")]) - parent = System(name="S", channels=[_channel("ch", "IFace")], components=[a, b]) + """Source port belongs to src_entity node; target port to dst_entity node.""" + a = Component(name="A", requires=[_iref("IFace")]) + b = Component(name="B", provides=[_iref("IFace")]) + # B is src (provider), A is dst (requirer) + parent = System( + name="S", + connects=[_connect("B", "IFace", "A", "IFace", channel="ch")], + components=[a, b], + ) diag = build_viz_diagram(parent) all_ports = collect_all_ports(diag) edge = diag.edges[0] - assert all_ports[edge.source_port_id].node_id == "S__A" - assert all_ports[edge.target_port_id].node_id == "S__B" + assert all_ports[edge.source_port_id].node_id == "S__B" + assert all_ports[edge.target_port_id].node_id == "S__A" def test_edge_protocol_and_async_propagated() -> None: - """Channel protocol and async attributes are propagated to the edge.""" - a = Component(name="A", requires=[_iref("X", via="ch")]) - b = Component(name="B", provides=[_iref("X", via="ch")]) + """Connect protocol and async attributes are propagated to the edge.""" + a = Component(name="A", requires=[_iref("X")]) + b = Component(name="B", provides=[_iref("X")]) parent = System( name="S", - channels=[_channel("ch", "X", protocol="gRPC", is_async=True, description="async call")], + connects=[_connect("B", "X", "A", "X", channel="ch", protocol="gRPC", is_async=True, description="async call")], components=[a, b], ) diag = build_viz_diagram(parent) @@ -421,30 +456,30 @@ def test_edge_protocol_and_async_propagated() -> None: assert edge.description == "async call" -def test_multiple_edges_from_multiple_channels() -> None: - """Multiple channels each produce their own edges.""" - a = Component( - name="A", - requires=[_iref("X", via="ch1"), _iref("Y", via="ch2")], - ) - b = Component(name="B", provides=[_iref("X", via="ch1")]) - c = Component(name="C", provides=[_iref("Y", via="ch2")]) +def test_multiple_edges_from_multiple_connects() -> None: + """Multiple connect statements each produce their own edge.""" + a = Component(name="A", requires=[_iref("X"), _iref("Y")]) + b = Component(name="B", provides=[_iref("X")]) + c = Component(name="C", provides=[_iref("Y")]) parent = System( name="S", - channels=[_channel("ch1", "X"), _channel("ch2", "Y")], + connects=[ + _connect("B", "X", "A", "X", channel="ch1"), + _connect("C", "Y", "A", "Y", channel="ch2"), + ], components=[a, b, c], ) diag = build_viz_diagram(parent) assert len(diag.edges) == 2 -def test_external_component_binds_to_channel() -> None: - """An external component (is_external=True) can bind to a channel.""" - stripe = Component(name="StripeAPI", is_external=True, requires=[_iref("PaymentRequest", via="payment")]) - gateway = Component(name="PaymentGateway", provides=[_iref("PaymentRequest", via="payment")]) +def test_external_component_connected() -> None: + """An external component (is_external=True) can be wired via connect.""" + stripe = Component(name="StripeAPI", is_external=True, requires=[_iref("PaymentRequest")]) + gateway = Component(name="PaymentGateway", provides=[_iref("PaymentRequest")]) parent = System( name="S", - channels=[_channel("payment", "PaymentRequest")], + connects=[_connect("PaymentGateway", "PaymentRequest", "StripeAPI", "PaymentRequest", channel="payment")], components=[stripe, gateway], ) diag = build_viz_diagram(parent) @@ -452,7 +487,7 @@ def test_external_component_binds_to_channel() -> None: child_labels = {c.label for c in diag.root.children} assert "StripeAPI" in child_labels assert "PaymentGateway" in child_labels - # One edge connects them through the channel. + # One edge connects them. assert len(diag.edges) == 1 stripe_node = next(c for c in diag.root.children if c.label == "StripeAPI") assert isinstance(stripe_node, VizNode) @@ -461,23 +496,30 @@ def test_external_component_binds_to_channel() -> None: def test_child_component_not_in_peripheral_nodes() -> None: """Direct children of the focus entity never appear in peripheral_nodes.""" - a = Component(name="A", requires=[_iref("IFace", via="ch")]) - b = Component(name="B", provides=[_iref("IFace", via="ch")]) - parent = System(name="S", channels=[_channel("ch", "IFace")], components=[a, b]) + a = Component(name="A", requires=[_iref("IFace")]) + b = Component(name="B", provides=[_iref("IFace")]) + parent = System( + name="S", + connects=[_connect("B", "IFace", "A", "IFace", channel="ch")], + components=[a, b], + ) diag = build_viz_diagram(parent) peripheral_labels = {n.label for n in diag.peripheral_nodes} assert "A" not in peripheral_labels assert "B" not in peripheral_labels -def test_many_requirers_one_provider_creates_n_edges() -> None: - """N requirers × 1 provider on the same channel creates N edges.""" - a = Component(name="A", requires=[_iref("X", via="ch")]) - b = Component(name="B", requires=[_iref("X", via="ch")]) - c = Component(name="C", provides=[_iref("X", via="ch")]) +def test_one_provider_multiple_requirers_creates_n_edges() -> None: + """One provider connected to N requirers via separate connects creates N edges.""" + a = Component(name="A", requires=[_iref("X")]) + b = Component(name="B", requires=[_iref("X")]) + c = Component(name="C", provides=[_iref("X")]) parent = System( name="S", - channels=[_channel("ch", "X")], + connects=[ + _connect("C", "X", "A", "X", channel="ch"), + _connect("C", "X", "B", "X", channel="ch"), + ], components=[a, b, c], ) diag = build_viz_diagram(parent) @@ -517,9 +559,13 @@ def test_collect_all_ports_includes_terminal_ports() -> None: def test_collect_all_ports_returns_unique_ids() -> None: """All returned port IDs are distinct.""" - a = Component(name="A", requires=[_iref("X", via="ch")]) - b = Component(name="B", provides=[_iref("X", via="ch")]) - parent = System(name="S", channels=[_channel("ch", "X")], components=[a, b]) + a = Component(name="A", requires=[_iref("X")]) + b = Component(name="B", provides=[_iref("X")]) + parent = System( + name="S", + connects=[_connect("B", "X", "A", "X", channel="ch")], + components=[a, b], + ) diag = build_viz_diagram(parent) all_ports = collect_all_ports(diag) assert len(all_ports) == len(set(all_ports)) @@ -535,38 +581,43 @@ def test_ecommerce_system_topology() -> None: order_svc = Component( name="OrderService", title="Order Service", - requires=[ - _iref("PaymentRequest", via="payment"), - _iref("InventoryCheck", via="inventory"), - ], + requires=[_iref("PaymentRequest"), _iref("InventoryCheck")], provides=[_iref("OrderConfirmation")], ) payment_gw = Component( name="PaymentGateway", title="Payment Gateway", tags=["critical", "pci-scope"], - provides=[_iref("PaymentRequest", via="payment")], - requires=[_iref("StripePayment", via="stripe")], + provides=[_iref("PaymentRequest")], + requires=[_iref("StripePayment")], ) stripe = Component( name="StripeAPI", title="Stripe Payment API", is_external=True, - provides=[_iref("StripePayment", via="stripe")], + provides=[_iref("StripePayment")], ) inventory = Component( name="InventoryManager", title="Inventory Manager", - provides=[_iref("InventoryCheck", via="inventory")], + provides=[_iref("InventoryCheck")], ) ecommerce = System( name="ECommerce", title="E-Commerce Platform", - channels=[ - _channel("payment", "PaymentRequest"), - _channel("inventory", "InventoryCheck"), - _channel("stripe", "StripePayment", protocol="HTTP", is_async=True), + connects=[ + _connect("PaymentGateway", "PaymentRequest", "OrderService", "PaymentRequest", channel="payment"), + _connect("InventoryManager", "InventoryCheck", "OrderService", "InventoryCheck", channel="inventory"), + _connect( + "StripeAPI", + "StripePayment", + "PaymentGateway", + "StripePayment", + channel="stripe", + protocol="HTTP", + is_async=True, + ), ], components=[order_svc, payment_gw, inventory, stripe], ) @@ -589,7 +640,7 @@ def test_ecommerce_system_topology() -> None: assert isinstance(stripe_node, VizNode) assert stripe_node.kind == "external_component" - # Three edges (one per channel that has both a provider and requirer). + # Three edges (one per connect statement). assert len(diag.edges) == 3 edge_labels = {e.label for e in diag.edges} assert "PaymentRequest" in edge_labels @@ -653,23 +704,31 @@ def test_user_child_ports_are_built() -> None: assert "provides" in directions -def test_user_channel_binding_creates_edge() -> None: - """A user binding to a channel via 'via' creates an edge.""" - customer = UserDef(name="Customer", provides=[InterfaceRef(name="OrderRequest", via="order_in")]) - order_svc = Component(name="OrderService", requires=[InterfaceRef(name="OrderRequest", via="order_in")]) +def test_user_connect_creates_edge() -> None: + """A connect statement involving a user entity creates an edge.""" + customer = UserDef(name="Customer", provides=[InterfaceRef(name="OrderRequest")]) + order_svc = Component(name="OrderService", requires=[InterfaceRef(name="OrderRequest")]) system = System( name="ECommerce", - channels=[ChannelDef(name="order_in", interface=InterfaceRef(name="OrderRequest"))], + connects=[ + ConnectDef( + src_entity="Customer", + src_port="OrderRequest", + channel="order_in", + dst_entity="OrderService", + dst_port="OrderRequest", + ) + ], users=[customer], components=[order_svc], ) diag = build_viz_diagram(system) assert len(diag.edges) == 1 edge = diag.edges[0] - # OrderService is the requirer → source port - # Customer is the provider → target port - assert "OrderService" in edge.source_port_id - assert "Customer" in edge.target_port_id + # Customer is the provider → source port + # OrderService is the requirer → target port + assert "Customer" in edge.source_port_id + assert "OrderService" in edge.target_port_id all_ports = collect_all_ports(diag) assert edge.source_port_id in all_ports assert edge.target_port_id in all_ports