From 0cdc64dd87dd6b029b536c65a329bc47e813a8e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 15:57:46 +0000 Subject: [PATCH 1/6] test: red tests for interface contract parsing and enforcement Interfaces currently parse only as bare declarations: 'create interface X' takes no body, required_actions is always empty, and 'implements' is never checked anywhere at runtime. These tests pin the intended behavior: requires-action bodies, interface extends, runtime conformance enforcement (missing action, wrong arity, unknown interface), inherited-method satisfaction, and backward compatibility for bare interfaces. Red evidence: interface_body_with_required_actions_parses, interface_extends_parses, container_satisfying_interface_runs, container_missing_required_action_fails_at_runtime, inherited_method_satisfies_interface, and implementing_unknown_interface_fails_at_runtime all fail against the current implementation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KEThJZTipEWKQvLxQWxd7R --- tests/interface_contract_test.rs | 356 +++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 tests/interface_contract_test.rs diff --git a/tests/interface_contract_test.rs b/tests/interface_contract_test.rs new file mode 100644 index 00000000..bb585477 --- /dev/null +++ b/tests/interface_contract_test.rs @@ -0,0 +1,356 @@ +// TDD tests for interface contracts (Red first). +// +// Interfaces previously parsed only as bare declarations (`create interface X`) +// with no body and no enforcement: a container could claim `implements X` for +// any X and nothing ever checked conformance. These tests pin down the wired-up +// behavior: +// * `create interface Name:` bodies with `requires action ...` signatures +// * backward-compatible bare `create interface Name` (empty contract) +// * `extends` between interfaces (requirements accumulate) +// * runtime rejection of a container whose `implements` list is unsatisfied +// * inherited container methods satisfy interface requirements + +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; +use wfl::parser::ast::Statement; + +mod test_helpers; +use test_helpers::*; + +fn parse(source: &str) -> Result> { + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + parser.parse() +} + +// === Parser: interface bodies === + +#[test] +fn interface_body_with_required_actions_parses() { + let source = r#" +create interface Drawable: + requires action draw + requires action get_area: Number + requires action resize needs w: Number, h: Number +end +"#; + let program = parse(source).expect("interface body should parse"); + let stmt = program + .statements + .iter() + .find(|s| matches!(s, Statement::InterfaceDefinition { .. })) + .expect("expected an InterfaceDefinition statement"); + + if let Statement::InterfaceDefinition { + name, + required_actions, + .. + } = stmt + { + assert_eq!(name, "Drawable"); + assert_eq!( + required_actions.len(), + 3, + "all three required actions should be captured" + ); + let draw = required_actions + .iter() + .find(|a| a.name == "draw") + .expect("draw signature"); + assert!(draw.parameters.is_empty()); + assert!(draw.return_type.is_none()); + + let get_area = required_actions + .iter() + .find(|a| a.name == "get_area") + .expect("get_area signature"); + assert!(get_area.return_type.is_some(), "return type recorded"); + + let resize = required_actions + .iter() + .find(|a| a.name == "resize") + .expect("resize signature"); + assert_eq!(resize.parameters.len(), 2, "parameters recorded"); + } else { + unreachable!(); + } +} + +#[test] +fn bare_interface_still_parses_as_empty_contract() { + let source = r#" +create interface Drawable + +create container Rectangle implements Drawable: + property width: Number + + action draw: + display "drawing" + end +end +"#; + let program = parse(source).expect("bare interface must keep parsing (backward compat)"); + let stmt = program + .statements + .iter() + .find(|s| matches!(s, Statement::InterfaceDefinition { .. })) + .expect("expected an InterfaceDefinition statement"); + if let Statement::InterfaceDefinition { + required_actions, .. + } = stmt + { + assert!(required_actions.is_empty()); + } +} + +#[test] +fn interface_extends_parses() { + let source = r#" +create interface Drawable: + requires action draw +end + +create interface Shape extends Drawable: + requires action get_area: Number +end +"#; + let program = parse(source).expect("interface extends should parse"); + let shape = program + .statements + .iter() + .find_map(|s| match s { + Statement::InterfaceDefinition { name, extends, .. } if name == "Shape" => { + Some(extends.clone()) + } + _ => None, + }) + .expect("Shape interface parsed"); + assert_eq!(shape, vec!["Drawable".to_string()]); +} + +#[test] +fn interface_requires_rejects_missing_action_keyword() { + let source = r#" +create interface Broken: + requires draw +end +"#; + assert!( + parse(source).is_err(), + "'requires' without 'action' should be a parse error" + ); +} + +// === Runtime: conformance enforcement === + +#[test] +fn container_satisfying_interface_runs() { + let program = r#" +create interface Drawable: + requires action draw + requires action get_area: Number +end + +create container Rectangle implements Drawable: + property width: Number + property height: Number + + action draw: + display "Drawing rectangle: " with width with " x " with height + end + + action get_area: Number + return width times height + end +end + +create new Rectangle as rect: + width is 10 + height is 5 +end + +rect.draw() +store area as rect.get_area() +display "Area: " with area +"#; + let output = run_wfl_program(program, "iface_conformant"); + assert_wfl_success_with_output( + &output, + &["Drawing rectangle: 10 x 5", "Area: 50"], + &["error"], + ); +} + +#[test] +fn container_missing_required_action_fails_at_runtime() { + let program = r#" +create interface Drawable: + requires action draw + requires action get_area: Number +end + +create container Circle implements Drawable: + property radius: Number + + action draw: + display "Drawing circle" + end +end + +display "should not get here" +"#; + let output = run_wfl_program(program, "iface_missing_action"); + assert!( + !output.status.success(), + "a container missing a required action must fail, got stdout: {} stderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Circle") && stderr.contains("Drawable") && stderr.contains("get_area"), + "error should name the container, interface, and missing action; got: {stderr}" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + !stdout.contains("should not get here"), + "program must not continue past the unsatisfied contract" + ); +} + +#[test] +fn container_with_wrong_arity_fails_at_runtime() { + let program = r#" +create interface Resizable: + requires action resize needs w: Number, h: Number +end + +create container Box implements Resizable: + property size: Number + + action resize needs s: Number: + store size as s + end +end + +display "should not get here" +"#; + let output = run_wfl_program(program, "iface_wrong_arity"); + assert!( + !output.status.success(), + "an arity mismatch against the interface must fail" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("resize"), + "error should name the mismatched action; got: {stderr}" + ); +} + +#[test] +fn inherited_method_satisfies_interface() { + let program = r#" +create interface Greeter: + requires action greet +end + +create container Person: + property name: Text + + action greet: + display "Hello, I am " with name + end +end + +create container Employee extends Person implements Greeter: + property job_title: Text +end + +create new Employee as bob: + name is "Bob" + job_title is "Developer" +end + +bob.greet() +"#; + let output = run_wfl_program(program, "iface_inherited"); + assert_wfl_success_with_output(&output, &["Hello, I am Bob"], &["error"]); +} + +#[test] +fn extended_interface_requirements_are_enforced() { + let program = r#" +create interface Drawable: + requires action draw +end + +create interface Shape extends Drawable: + requires action get_area: Number +end + +create container Blob implements Shape: + property size: Number + + action get_area: Number + return size times size + end +end + +display "should not get here" +"#; + let output = run_wfl_program(program, "iface_extends_enforced"); + assert!( + !output.status.success(), + "requirements inherited from an extended interface must be enforced" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("draw"), + "error should name the missing inherited requirement; got: {stderr}" + ); +} + +#[test] +fn implementing_unknown_interface_fails_at_runtime() { + let program = r#" +create container Widget implements NoSuchInterface: + property id: Number + + action ping: + display "pong" + end +end + +display "should not get here" +"#; + let output = run_wfl_program(program, "iface_unknown"); + assert!( + !output.status.success(), + "implementing an undefined interface must fail" + ); +} + +#[test] +fn bare_interface_accepts_any_implementer_at_runtime() { + // Backward compatibility: existing programs use `create interface X` with + // no body; that is an empty contract every container satisfies. + let program = r#" +create interface Marker + +create container Anything implements Marker: + property id: Number + + action ping: + display "pong" + end +end + +create new Anything as a: + id is 1 +end + +a.ping() +"#; + let output = run_wfl_program(program, "iface_bare_ok"); + assert_wfl_success_with_output(&output, &["pong"], &["error"]); +} From 513bdacc374f55bfae784ec99eae62505d09b0dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:31:07 +0000 Subject: [PATCH 2/6] feat: enforce interface contracts on containers; remove dead parser files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interfaces were decorative: 'create interface X' parsed only as a bare declaration (no body, no required actions) and nothing ever verified that a container claiming 'implements X' provided anything — even implementing an undefined interface ran fine. The containers doc promised 'contracts that containers must fulfill'; this makes that true. - parser: interface bodies ('requires action ', optional 'needs' parameter list, optional ': ReturnType') and 'extends' between interfaces. The 'requires' keyword was lexed but never parsed until now. Bare 'create interface Name' still parses as an empty contract, so existing marker interfaces keep working. - interpreter: container definitions now validate conformance — every required action (accumulated through interface extends chains) must exist with the same parameter count, on the container or inherited via its extends chain. Unknown or non-interface names in 'implements' are runtime errors. - analyzer/typechecker: new InterfaceInfo registry plus the same conformance check statically, so LSP/tooling surfaces breaches before execution. - dead code: delete src/parser/container_ast.rs (duplicate AST types) and src/parser/container_parser.rs (empty stub); neither was declared as a module anywhere, so neither was even compiled. - docs: containers-oop.md Interfaces section documents the enforced syntax, breach error, interface inheritance, and marker interfaces; fixed stale keyword examples ('define interface called', 'requires method') in reserved-keywords.md. - tests: tests/interface_contract_test.rs (Red commit precedes this), TestPrograms/containers/interface_contracts.wfl, TestPrograms/error_examples/interface_missing_action.wfl, four registered docs_examples/containers/ files, and containers_comprehensive.wfl now exercises a real contract. Risk class R3 (backward compatibility): full TestPrograms suite and all containers-oop.md examples re-run green against the release binary. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KEThJZTipEWKQvLxQWxd7R --- Docs/04-advanced-features/containers-oop.md | 67 ++++++- Docs/reference/reserved-keywords.md | 8 +- ...2026-08-13-interface-contracts-enforced.md | 79 ++++++++ .../containers/interface_contracts.wfl | 136 +++++++++++++ TestPrograms/containers_comprehensive.wfl | 5 +- .../docs_examples/_meta/manifest.json | 75 ++++++++ .../containers/basic_container_01.wfl | 15 ++ .../interface_missing_action_01.wfl | 7 + .../containers/interfaces_01.wfl | 26 +++ .../containers/task_manager_01.wfl | 71 +++++++ .../interface_missing_action.wfl | 11 ++ src/analyzer/mod.rs | 50 ++++- src/interpreter/mod.rs | 118 ++++++++++++ src/parser/container_ast.rs | 182 ------------------ src/parser/container_parser.rs | 4 - src/parser/stmt/containers.rs | 161 +++++++++++++++- src/typechecker/mod.rs | 74 +++++++ 17 files changed, 888 insertions(+), 201 deletions(-) create mode 100644 History/dev-diary/2026/2026-08-13-interface-contracts-enforced.md create mode 100644 TestPrograms/containers/interface_contracts.wfl create mode 100644 TestPrograms/docs_examples/containers/basic_container_01.wfl create mode 100644 TestPrograms/docs_examples/containers/interface_missing_action_01.wfl create mode 100644 TestPrograms/docs_examples/containers/interfaces_01.wfl create mode 100644 TestPrograms/docs_examples/containers/task_manager_01.wfl create mode 100644 TestPrograms/error_examples/interface_missing_action.wfl delete mode 100644 src/parser/container_ast.rs delete mode 100644 src/parser/container_parser.rs diff --git a/Docs/04-advanced-features/containers-oop.md b/Docs/04-advanced-features/containers-oop.md index b8d8e521..3053b253 100644 --- a/Docs/04-advanced-features/containers-oop.md +++ b/Docs/04-advanced-features/containers-oop.md @@ -226,10 +226,14 @@ buddy.make_sound() ## Interfaces -Interfaces define contracts that containers must fulfill: +Interfaces define contracts that containers must fulfill. An interface body +lists the actions every implementing container is **required** to provide: ```wfl -create interface Drawable +create interface Drawable: + requires action draw + requires action get_area: Number +end create container Rectangle implements Drawable: property width: Number @@ -254,6 +258,63 @@ store area as rect.get_area() display "Area: " with area ``` +**Syntax:** +```wfl +create interface : + requires action + requires action : + requires action needs : , : +end +``` + +### Contracts Are Enforced + +A container that claims `implements X` but does not provide every required +action is rejected. The static checker reports the breach, and the program +stops with an error when the container definition runs: + +```wfl +create interface Drawable: + requires action draw +end + +create container Circle implements Drawable: + property radius: Number +end + +// Error: Container 'Circle' does not satisfy interface 'Drawable': +// missing required action 'draw' +``` + +A required action with parameters must be implemented with the same number of +parameters. A requirement may also be satisfied by an action inherited from a +parent container (`extends`). + +### Interface Inheritance + +Interfaces can extend other interfaces; the requirements accumulate: + +```wfl +create interface Drawable: + requires action draw +end + +create interface Shape extends Drawable: + requires action get_area: Number +end + +// A container implementing Shape must provide BOTH draw and get_area. +``` + +### Marker Interfaces + +An interface without a body is an empty contract — useful as a marker or tag +that any container can implement: + +```wfl +create interface Serializable +``` + ## Complete Example: Task Manager ```wfl @@ -358,7 +419,7 @@ In this section, you learned: ✅ **Creating instances** - `create new` ✅ **Calling actions** - `object.action()` ✅ **Inheritance** - `extends` keyword -✅ **Interfaces** - `implements` keyword +✅ **Interfaces** - `implements` keyword, contracts enforced via `requires action` ✅ **Complete examples** - Task manager with OOP ## Next Steps diff --git a/Docs/reference/reserved-keywords.md b/Docs/reference/reserved-keywords.md index 82f16f32..95a4eb0a 100644 --- a/Docs/reference/reserved-keywords.md +++ b/Docs/reference/reserved-keywords.md @@ -163,7 +163,7 @@ These keywords **MUST** always be reserved and **CANNOT** be used as variable na | `if` | Conditional | `check if x is 5:` | | `implements` | Interface implementation | `container Dog implements Animal:` | | `in` | For each collection | `for each item in list:` | -| `interface` | Interface definition | `define interface called Runnable:` | +| `interface` | Interface definition | `create interface Runnable:` | | `load` | Load module | `load module math` | | `module` | Module reference | `load module fs` | | `not` | Logical NOT | `check if not x:` | @@ -175,7 +175,7 @@ These keywords **MUST** always be reserved and **CANNOT** be used as variable na | `public` | Public visibility | `public property name` | | `push` | Add to list | `push with myList and item` | | `repeat` | Loop construct | `repeat 10 times:` | -| `requires` | Interface requirement | `requires method run` | +| `requires` | Interface requirement | `requires action run` | | `return` | Return value | `return result` | | `route` | Dispatch on a value (match/switch) | `route path:` | | `skip` | Continue (alias) | `skip` | @@ -629,7 +629,7 @@ Complete reference table of all 181 keywords. | `if` | Structural | Control Flow | ❌ | `check if` | | `implements` | Structural | OOP | ❌ | `implements interface` | | `in` | Structural | Control Flow | ❌ | `for each in` | -| `interface` | Structural | OOP | ❌ | `define interface` | +| `interface` | Structural | OOP | ❌ | `create interface` | | `into` | Other | Process | ❌ | `output into` | | `is` | Other | Comparison | ❌ | `x is 5` | | `kill` | Other | Process | ❌ | `kill process` | @@ -683,7 +683,7 @@ Complete reference table of all 181 keywords. | `repeat` | Structural | Control Flow | ❌ | `repeat 10 times` | | `replace` | Other | Pattern | ❌ | `replace pattern` | | `request` | Other | Web/Network | ❌ | `HTTP request` | -| `requires` | Structural | OOP | ❌ | `requires method` | +| `requires` | Structural | OOP | ❌ | `requires action` | | `respond` | Other | Web/Network | ❌ | `respond to request` | | `response` | Other | Web/Network | ❌ | `HTTP response` | | `return` | Structural | Operations | ❌ | `return value` | diff --git a/History/dev-diary/2026/2026-08-13-interface-contracts-enforced.md b/History/dev-diary/2026/2026-08-13-interface-contracts-enforced.md new file mode 100644 index 00000000..92a2c6d8 --- /dev/null +++ b/History/dev-diary/2026/2026-08-13-interface-contracts-enforced.md @@ -0,0 +1,79 @@ +# 2026-08-13 — Interfaces stop being decorative + +## What changed + +The containers doc (`Docs/04-advanced-features/containers-oop.md`) promised +"Interfaces define contracts that containers must fulfill." Auditing every +example in that page against the release binary showed the examples themselves +all ran and printed exactly what the doc claims — but the interface promise was +false. `create interface X` parsed only as a bare declaration: no body, no +required actions, and nothing anywhere in the pipeline ever checked that a +container claiming `implements X` provided anything at all. Even +`implements TotallyUndefinedInterface` executed happily at runtime (only the +type checker warned). + +Interfaces are now real contracts: + +```wfl +create interface Drawable: + requires action draw + requires action get_area: Number +end +``` + +- **Parser** — interface bodies with `requires action `, optional + `needs` parameter lists, optional `: ReturnType`, and `extends` between + interfaces (comma-separated list). The `requires` keyword existed in the + lexer since the beginning and was never consumed by the parser. Bare + `create interface Name` still parses as an empty contract, so existing + programs (marker interfaces) keep working. +- **Interpreter** — when a `create container … implements …` definition is + evaluated, every required action (accumulated through interface `extends` + chains) must be present with the same parameter count, either on the + container itself or inherited through its own `extends` chain. A breach is + a runtime error naming the container, the interface, and the missing or + mismatched action; an unknown or non-interface name in `implements` is also + an error now. +- **Analyzer/Type checker** — the analyzer records an `InterfaceInfo` + registry, and the type checker performs the same conformance check + statically so tooling (LSP, MCP, `wfl --analyze` users) sees the breach + before execution. + +## Dead code removed + +`src/parser/container_ast.rs` (181 lines of duplicate AST definitions) and +`src/parser/container_parser.rs` (an empty comment stub) were never declared +as modules anywhere — not compiled, not referenced. Both deleted. + +## TDD evidence + +Red commit `test: red tests for interface contract parsing and enforcement` +adds `tests/interface_contract_test.rs`; six of its tests fail against the +prior implementation (body parsing, extends parsing, missing-action rejection, +unknown-interface rejection, inherited satisfaction, conformant execution) and +all pass after the change. Risk class R3 (backward compatibility): the bare +interface form and the entire `TestPrograms/` suite were re-run against the +release binary, and every code example in the containers doc was executed +before and after. + +## Coverage added + +- `tests/interface_contract_test.rs` — parser + end-to-end binary tests. +- `TestPrograms/containers/interface_contracts.wfl` — positive coverage: + bodies, extends accumulation, parameterized requirements, inherited + satisfaction, marker interfaces. +- `TestPrograms/error_examples/interface_missing_action.wfl` — gated + expected-failure program. +- `TestPrograms/docs_examples/containers/` — four registered doc examples + (basic container, interface contract, enforcement error, task manager) + wired into `validate_docs_examples.py`. +- `TestPrograms/containers_comprehensive.wfl` — its interface section now + uses a real body, so the flagship container test exercises enforcement. + +## Doc honesty + +The Interfaces section of the containers doc now shows the enforced syntax, +the error a breach produces, interface inheritance, and marker interfaces. +The keyword references had two stale examples (`define interface called +Runnable:`, `requires method run`) that matched no grammar past or present; +both now show the real forms. diff --git a/TestPrograms/containers/interface_contracts.wfl b/TestPrograms/containers/interface_contracts.wfl new file mode 100644 index 00000000..11dd9efd --- /dev/null +++ b/TestPrograms/containers/interface_contracts.wfl @@ -0,0 +1,136 @@ +// Interface contract test - interfaces with required actions are enforced +// when a container definition claims to implement them. + +display "=== Interface Contract Test ===" + +// === Interface with a body: required actions === +create interface Drawable: + requires action draw + requires action get_area: Number +end + +create container Rectangle implements Drawable: + property width: Number + property height: Number + + action draw: + display "Drawing rectangle: " with width with " x " with height + end + + action get_area: Number + return width times height + end +end + +create new Rectangle as rect: + width is 10 + height is 5 +end + +rect.draw() +store area as rect.get_area() +check if area is equal to 50: + display "PASS: area is 50" +otherwise: + display "FAIL: area is " with area +end check + +// === Interface extends: requirements accumulate === +create interface Shape extends Drawable: + requires action describe_shape: Text +end + +create container Square implements Shape: + property side: Number + + action draw: + display "Drawing square: " with side + end + + action get_area: Number + return side times side + end + + action describe_shape: Text + return "a square with side " with side + end +end + +create new Square as sq: + side is 4 +end + +sq.draw() +store sq_desc as sq.describe_shape() +display "Square says: " with sq_desc +check if sq.get_area() is equal to 16: + display "PASS: square area is 16" +otherwise: + display "FAIL: square area is " with sq.get_area() +end check + +// === Required action with parameters === +create interface Resizable: + requires action resize needs w: Number, h: Number +end + +create container Panel implements Resizable: + property width: Number + property height: Number + + action resize needs w: Number, h: Number: + store width as w + store height as h + display "Panel resized to " with width with " x " with height + end +end + +create new Panel as panel: + width is 1 + height is 1 +end + +panel.resize(20, 30) + +// === Inherited methods satisfy interface requirements === +create interface Greeter: + requires action greet +end + +create container Person: + property name: Text + + action greet: + display "Hello, I am " with name + end +end + +create container Employee extends Person implements Greeter: + property job_title: Text +end + +create new Employee as bob: + name is "Bob" + job_title is "Developer" +end + +bob.greet() + +// === Bare interface: empty contract (backward compatible marker) === +create interface Marker + +create container Anything implements Marker: + property id: Number + + action ping: + display "pong" + end +end + +create new Anything as thing: + id is 1 +end + +thing.ping() + +display "=== Interface Contract Test Completed ===" diff --git a/TestPrograms/containers_comprehensive.wfl b/TestPrograms/containers_comprehensive.wfl index 70d7c6c8..07f35e5f 100644 --- a/TestPrograms/containers_comprehensive.wfl +++ b/TestPrograms/containers_comprehensive.wfl @@ -72,7 +72,10 @@ display "" // === Interface Implementation === display "3. Interface Implementation Test" -create interface Drawable +create interface Drawable: + requires action draw + requires action get_area: Number +end create container Rectangle implements Drawable: property width: Number diff --git a/TestPrograms/docs_examples/_meta/manifest.json b/TestPrograms/docs_examples/_meta/manifest.json index a1cf1c0d..8706fa60 100644 --- a/TestPrograms/docs_examples/_meta/manifest.json +++ b/TestPrograms/docs_examples/_meta/manifest.json @@ -505,5 +505,80 @@ "main-loop" ], "description": "Concurrent request handling with main loop concurrently." + }, + "docs_examples/containers/basic_container_01.wfl": { + "doc_section": "Docs/04-advanced-features/containers-oop.md#basic-container", + "type": "executable", + "validate_layers": [ + 1, + 2, + 3, + 4, + 5 + ], + "expected_exit_code": 0, + "tags": [ + "containers", + "oop", + "properties", + "actions" + ], + "doc_purpose": "Demonstrates defining a container, creating an instance, and calling an action" + }, + "docs_examples/containers/interfaces_01.wfl": { + "doc_section": "Docs/04-advanced-features/containers-oop.md#interfaces", + "type": "executable", + "validate_layers": [ + 1, + 2, + 3, + 4, + 5 + ], + "expected_exit_code": 0, + "tags": [ + "containers", + "oop", + "interfaces", + "requires" + ], + "doc_purpose": "Demonstrates an interface contract with required actions satisfied by an implementing container" + }, + "docs_examples/containers/interface_missing_action_01.wfl": { + "doc_section": "Docs/04-advanced-features/containers-oop.md#contracts-are-enforced", + "type": "error_example", + "validate_layers": [ + 1, + 2, + 5 + ], + "expected_exit_code": 1, + "expected_error_pattern": "does not satisfy interface", + "tags": [ + "containers", + "oop", + "interfaces", + "error" + ], + "doc_purpose": "Shows that a container missing a required interface action is rejected at runtime" + }, + "docs_examples/containers/task_manager_01.wfl": { + "doc_section": "Docs/04-advanced-features/containers-oop.md#complete-example-task-manager", + "type": "executable", + "validate_layers": [ + 1, + 2, + 3, + 4, + 5 + ], + "expected_exit_code": 0, + "tags": [ + "containers", + "oop", + "lists", + "complete-example" + ], + "doc_purpose": "Complete task manager example combining containers, lists, and actions" } } diff --git a/TestPrograms/docs_examples/containers/basic_container_01.wfl b/TestPrograms/docs_examples/containers/basic_container_01.wfl new file mode 100644 index 00000000..fd374fa3 --- /dev/null +++ b/TestPrograms/docs_examples/containers/basic_container_01.wfl @@ -0,0 +1,15 @@ +create container Person: + property name: Text + property age: Number + + action greet: + display "Hello, I am " with name + end +end + +create new Person as alice: + name is "Alice" + age is 28 +end + +alice.greet() diff --git a/TestPrograms/docs_examples/containers/interface_missing_action_01.wfl b/TestPrograms/docs_examples/containers/interface_missing_action_01.wfl new file mode 100644 index 00000000..77537b84 --- /dev/null +++ b/TestPrograms/docs_examples/containers/interface_missing_action_01.wfl @@ -0,0 +1,7 @@ +create interface Drawable: + requires action draw +end + +create container Circle implements Drawable: + property radius: Number +end diff --git a/TestPrograms/docs_examples/containers/interfaces_01.wfl b/TestPrograms/docs_examples/containers/interfaces_01.wfl new file mode 100644 index 00000000..6b8f56f8 --- /dev/null +++ b/TestPrograms/docs_examples/containers/interfaces_01.wfl @@ -0,0 +1,26 @@ +create interface Drawable: + requires action draw + requires action get_area: Number +end + +create container Rectangle implements Drawable: + property width: Number + property height: Number + + action draw: + display "Drawing rectangle: " with width with " x " with height + end + + action get_area: Number + return width times height + end +end + +create new Rectangle as rect: + width is 10 + height is 5 +end + +rect.draw() +store area as rect.get_area() +display "Area: " with area diff --git a/TestPrograms/docs_examples/containers/task_manager_01.wfl b/TestPrograms/docs_examples/containers/task_manager_01.wfl new file mode 100644 index 00000000..77979b8a --- /dev/null +++ b/TestPrograms/docs_examples/containers/task_manager_01.wfl @@ -0,0 +1,71 @@ +create container Task: + property description: Text + property completed: Boolean + property priority: Number + + action mark_complete: + store completed as yes + display "✓ Completed: " with description + end + + action set_priority needs level: Number: + store priority as level + end + + action to_string: Text + store mark as "☐" + check if completed is yes: + change mark to "✓" + end check + return mark with " " with description with " (P" with priority with ")" + end +end + +create container TaskList: + property tasks: List + + action add_task needs task: Task: + push with tasks and task + end + + action show_all: + display "=== Task List ===" + for each task in tasks: + store task_str as task.to_string() + display task_str + end for + end + + action complete_first: + check if length of tasks is greater than 0: + store first_task as tasks[0] + first_task.mark_complete() + end check + end +end + +// Usage +create new Task as task1: + description is "Learn WFL" + completed is no + priority is 1 +end + +create new Task as task2: + description is "Build web server" + completed is no + priority is 2 +end + +create new TaskList as my_tasks: + tasks is [] +end + +my_tasks.add_task(task1) +my_tasks.add_task(task2) +my_tasks.show_all() + +my_tasks.complete_first() + +display "" +my_tasks.show_all() diff --git a/TestPrograms/error_examples/interface_missing_action.wfl b/TestPrograms/error_examples/interface_missing_action.wfl new file mode 100644 index 00000000..1ef6e273 --- /dev/null +++ b/TestPrograms/error_examples/interface_missing_action.wfl @@ -0,0 +1,11 @@ +// Intentional error: Circle claims to implement Drawable but does not +// provide the required 'draw' action, so defining the container fails. +create interface Drawable: + requires action draw +end + +create container Circle implements Drawable: + property radius: Number +end + +display "unreachable: the container definition above must fail" diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 1c9488cb..1ff2adf5 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -101,6 +101,18 @@ pub struct ContainerInfo { pub column: usize, } +/// Static view of an interface contract: the actions a conforming container +/// must provide, plus the interfaces this one extends (whose requirements +/// accumulate onto implementers). +#[derive(Debug, Clone)] +pub struct InterfaceInfo { + pub name: String, + pub extends: Vec, + pub required_actions: HashMap, + pub line: usize, + pub column: usize, +} + #[derive(Debug, Clone)] pub struct PropertyInfo { pub name: String, @@ -370,6 +382,7 @@ pub struct Analyzer { /// constant shadowed in an inner scope is not mistaken for the outer one. constant_bindings: std::collections::HashSet, containers: HashMap, + interfaces: HashMap, events: HashMap, current_container: Option, /// Whether the currently analyzed container method is static. `None` @@ -658,6 +671,7 @@ impl Analyzer { action_parameters: std::collections::HashSet::new(), constant_bindings: std::collections::HashSet::new(), containers: HashMap::new(), + interfaces: HashMap::new(), events: HashMap::new(), current_container: None, current_method_is_static: None, @@ -735,6 +749,7 @@ impl Analyzer { self.action_parameters.clear(); self.constant_bindings.clear(); self.containers.clear(); + self.interfaces.clear(); self.events.clear(); self.current_container = None; self.current_method_is_static = None; @@ -2330,8 +2345,8 @@ impl Analyzer { Statement::InterfaceDefinition { name, - extends: _, - required_actions: _, + extends, + required_actions, line, column, } => { @@ -2347,6 +2362,33 @@ impl Analyzer { if let Err(e) = self.current_scope.define(interface_symbol) { self.errors.push(e); } + + // Record the contract so the type checker can verify that + // implementing containers actually provide these actions. + let mut actions = HashMap::new(); + for signature in required_actions { + actions.insert( + signature.name.clone(), + MethodInfo { + name: signature.name.clone(), + parameters: signature.parameters.clone(), + return_type: signature.return_type.clone().unwrap_or(Type::Unknown), + is_public: true, + line: signature.line, + column: signature.column, + }, + ); + } + self.interfaces.insert( + name.clone(), + InterfaceInfo { + name: name.clone(), + extends: extends.clone(), + required_actions: actions, + line: *line, + column: *column, + }, + ); } Statement::CreateListStatement { @@ -3536,6 +3578,10 @@ impl Analyzer { &self.containers } + pub fn get_interface(&self, name: &str) -> Option<&InterfaceInfo> { + self.interfaces.get(name) + } + fn enter_child_scope(&mut self, parent: Scope) { let scope_id = self.next_scope_id; self.next_scope_id = self.next_scope_id.saturating_add(1); diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 5cee0c68..59bbf3ae 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -10055,6 +10055,19 @@ impl Interpreter { container_events.insert(event.name.clone(), container_event); } + // Enforce interface contracts before the definition becomes + // visible: a container that claims 'implements X' must provide + // every action X (and anything X extends) requires. + Self::validate_interface_conformance( + &env, + name, + extends.as_ref(), + &container_methods, + implements, + *line, + *column, + )?; + let container_def = ContainerDefinitionValue { name: name.clone(), extends: extends.clone(), @@ -15333,6 +15346,111 @@ impl Interpreter { }) } + /// Validate that a container provides every action required by the + /// interfaces it claims to implement. Requirements accumulate through + /// interface `extends` chains, and a requirement may be satisfied by a + /// method inherited through the container's own `extends` chain. An + /// interface with no body (`create interface Name`) is an empty contract + /// that every container satisfies. + fn validate_interface_conformance( + env: &Rc>, + container_name: &str, + container_extends: Option<&String>, + container_methods: &HashMap, + implements: &[String], + line: usize, + column: usize, + ) -> Result<(), RuntimeError> { + // Resolve an instance method's parameter count: the container's own + // methods first, then the parent chain (bounded against definition + // cycles, which the environment cannot otherwise rule out). + let find_method_param_count = |method_name: &str| -> Option { + if let Some(method) = container_methods.get(method_name) { + return Some(method.params.len()); + } + let mut parent_name = container_extends.cloned(); + let mut visited_parents = HashSet::new(); + while let Some(name) = parent_name { + if !visited_parents.insert(name.clone()) { + return None; + } + let parent = match env.borrow().get(&name) { + Some(Value::ContainerDefinition(definition)) => definition, + _ => return None, + }; + if let Some(method) = parent.methods.get(method_name) { + return Some(method.params.len()); + } + parent_name = parent.extends.clone(); + } + None + }; + + for interface_name in implements { + let mut pending = vec![interface_name.clone()]; + let mut visited = HashSet::new(); + while let Some(current_name) = pending.pop() { + if !visited.insert(current_name.clone()) { + continue; + } + let interface = match env.borrow().get(¤t_name) { + Some(Value::InterfaceDefinition(definition)) => definition, + Some(_) => { + return Err(RuntimeError::new( + format!( + "Container '{container_name}' implements '{current_name}', but '{current_name}' is not an interface" + ), + line, + column, + )); + } + None => { + return Err(RuntimeError::new( + format!( + "Interface '{current_name}' not found for container '{container_name}'" + ), + line, + column, + )); + } + }; + + for parent in &interface.extends { + pending.push(parent.clone()); + } + + for (action_name, signature) in &interface.required_actions { + match find_method_param_count(action_name) { + None => { + return Err(RuntimeError::new( + format!( + "Container '{container_name}' does not satisfy interface '{}': missing required action '{action_name}'", + interface.name + ), + line, + column, + )); + } + Some(count) if count != signature.params.len() => { + return Err(RuntimeError::new( + format!( + "Container '{container_name}' does not satisfy interface '{}': action '{action_name}' takes {count} parameter(s) but the interface requires {}", + interface.name, + signature.params.len() + ), + line, + column, + )); + } + Some(_) => {} + } + } + } + } + + Ok(()) + } + fn resolve_static_property( env: &Rc>, mut definition: Rc, diff --git a/src/parser/container_ast.rs b/src/parser/container_ast.rs deleted file mode 100644 index e297f8ea..00000000 --- a/src/parser/container_ast.rs +++ /dev/null @@ -1,182 +0,0 @@ -use super::ast::{Argument, Expression, Parameter, Statement, Type}; -use std::fmt; - -/// Represents the visibility of a container member (property or method) -#[derive(Debug, Clone, PartialEq)] -pub enum Visibility { - Public, - Private, -} - -impl Default for Visibility { - fn default() -> Self { - Visibility::Public // Default to public visibility - } -} - -/// Represents a validation rule for a property -#[derive(Debug, Clone, PartialEq)] -pub struct ValidationRule { - pub rule_type: ValidationRuleType, - pub parameters: Vec, - pub line: usize, - pub column: usize, -} - -/// Types of validation rules that can be applied to properties -#[derive(Debug, Clone, PartialEq)] -pub enum ValidationRuleType { - NotEmpty, - MinLength, - MaxLength, - ExactLength, - MinValue, - MaxValue, - Pattern, - Custom, -} - -/// Represents a property definition in a container -#[derive(Debug, Clone, PartialEq)] -pub struct PropertyDefinition { - pub name: String, - pub property_type: Option, - pub default_value: Option, - pub validation_rules: Vec, - pub visibility: Visibility, - pub is_static: bool, - pub line: usize, - pub column: usize, -} - -/// Represents a property initializer in a container instantiation -#[derive(Debug, Clone, PartialEq)] -pub struct PropertyInitializer { - pub name: String, - pub value: Expression, - pub line: usize, - pub column: usize, -} - -/// Represents an action signature in an interface -#[derive(Debug, Clone, PartialEq)] -pub struct ActionSignature { - pub name: String, - pub parameters: Vec, - pub return_type: Option, - pub line: usize, - pub column: usize, -} - -/// Container-related statements to be added to the Statement enum -#[derive(Debug, Clone, PartialEq)] -pub enum ContainerStatement { - /// Container definition statement - ContainerDefinition { - name: String, - extends: Option, - implements: Vec, - properties: Vec, - methods: Vec, // ActionDefinition statements - events: Vec, - static_properties: Vec, - static_methods: Vec, // ActionDefinition statements - line: usize, - column: usize, - }, - - /// Container instantiation statement - ContainerInstantiation { - container_type: String, - instance_name: String, - arguments: Vec, - property_initializers: Vec, - line: usize, - column: usize, - }, - - /// Interface definition statement - InterfaceDefinition { - name: String, - extends: Vec, - required_actions: Vec, - line: usize, - column: usize, - }, - - /// Event definition statement - EventDefinition { - name: String, - parameters: Vec, - line: usize, - column: usize, - }, - - /// Event trigger statement - EventTrigger { - name: String, - arguments: Vec, - line: usize, - column: usize, - }, - - /// Event handler statement - EventHandler { - event_source: Expression, - event_name: String, - handler_body: Vec, - line: usize, - column: usize, - }, - - /// Parent method call statement - ParentMethodCall { - method_name: String, - arguments: Vec, - line: usize, - column: usize, - }, -} - -/// Represents an event definition in a container -#[derive(Debug, Clone, PartialEq)] -pub struct EventDefinition { - pub name: String, - pub parameters: Vec, - pub line: usize, - pub column: usize, -} - -/// Container-related expressions to be added to the Expression enum -#[derive(Debug, Clone, PartialEq)] -pub enum ContainerExpression { - /// Static member access expression - StaticMemberAccess { - container: String, - member: String, - line: usize, - column: usize, - }, - - /// Method call expression - MethodCall { - object: Box, - method: String, - arguments: Vec, - line: usize, - column: usize, - }, -} - -/// Container-related types to be added to the Type enum -#[derive(Debug, Clone, PartialEq)] -pub enum ContainerType { - /// Container type - Container(String), - - /// Container instance type - ContainerInstance(String), - - /// Interface type - Interface(String), -} \ No newline at end of file diff --git a/src/parser/container_parser.rs b/src/parser/container_parser.rs deleted file mode 100644 index 29855a0c..00000000 --- a/src/parser/container_parser.rs +++ /dev/null @@ -1,4 +0,0 @@ -// Container parsing implementation has been integrated directly into the main parser. -// -// -// diff --git a/src/parser/stmt/containers.rs b/src/parser/stmt/containers.rs index 79eedc44..c28dd8c2 100644 --- a/src/parser/stmt/containers.rs +++ b/src/parser/stmt/containers.rs @@ -1,8 +1,8 @@ //! Container (OOP) statement parsing use super::super::{ - Argument, EventDefinition, ParseError, Parser, PropertyDefinition, PropertyInitializer, - Statement, Type, Visibility, + ActionSignature, Argument, EventDefinition, ParseError, Parser, PropertyDefinition, + PropertyInitializer, Statement, Type, Visibility, }; use super::actions::colon_type_from_token; use super::{ActionParser, StmtParser}; @@ -15,6 +15,7 @@ pub(crate) trait ContainerParser<'a>: ExprParser<'a> + ActionParser<'a> { Self: StmtParser<'a>; fn parse_interface_definition(&mut self) -> Result; + fn parse_interface_body(&mut self) -> Result, ParseError>; fn parse_container_instantiation(&mut self) -> Result; fn parse_event_definition(&mut self) -> Result; fn parse_event_trigger(&mut self) -> Result; @@ -184,16 +185,166 @@ impl<'a> ContainerParser<'a> for Parser<'a> { )); }; - // For now, just create a simple interface definition + // Optional 'extends' with one or more parent interfaces + let mut extends = Vec::new(); + if let Some(token) = self.cursor.peek() + && token.token == Token::KeywordExtends + { + let extends_token = self.bump_sync().unwrap(); // Consume 'extends' + loop { + if let Some(token) = self.cursor.peek() { + if let Token::Identifier(id) = &token.token { + extends.push(id.clone()); + self.bump_sync(); // Consume the identifier + if let Some(next_token) = self.cursor.peek() + && next_token.token == Token::Comma + { + self.bump_sync(); // Consume comma + continue; + } + break; + } else { + return Err(ParseError::from_token( + "Expected interface name after 'extends'".to_string(), + token, + )); + } + } else { + return Err(ParseError::from_token( + "Expected interface name after 'extends'".to_string(), + extends_token, + )); + } + } + } + + // A bare 'create interface Name' (no colon) stays valid as an empty + // contract for backward compatibility. A colon opens a body of + // 'requires action ...' signatures terminated by 'end'. + let mut required_actions = Vec::new(); + if let Some(token) = self.cursor.peek() + && token.token == Token::Colon + { + self.bump_sync(); // Consume ':' + required_actions = self.parse_interface_body()?; + } + Ok(Statement::InterfaceDefinition { name, - extends: Vec::new(), - required_actions: Vec::new(), + extends, + required_actions, line, column, }) } + fn parse_interface_body(&mut self) -> Result, ParseError> { + let mut required_actions = Vec::new(); + + loop { + let Some(token) = self.cursor.peek() else { + return Err(ParseError::from_span( + "Unexpected end of input in interface body".to_string(), + crate::diagnostics::Span { start: 0, end: 0 }, + 0, + 0, + )); + }; + + match &token.token { + Token::KeywordEnd => { + self.bump_sync(); // Consume 'end' + break; + } + Token::Eol => { + self.bump_sync(); // Skip Eol between requirements + continue; + } + Token::KeywordRequires => { + let requires_token = self.bump_sync().unwrap(); // Consume 'requires' + let line = requires_token.line; + let column = requires_token.column; + + self.expect_token( + Token::KeywordAction, + "Expected 'action' after 'requires' in interface body", + )?; + + let name = if let Some(token) = self.cursor.peek() { + if let Token::Identifier(id) = &token.token { + self.bump_sync(); // Consume the identifier + id.clone() + } else { + return Err(ParseError::from_token( + "Expected action name after 'requires action'".to_string(), + token, + )); + } + } else { + return Err(ParseError::from_token( + "Expected action name after 'requires action'".to_string(), + requires_token, + )); + }; + + let mut parameters = Vec::new(); + if let Some(token) = self.cursor.peek() + && matches!(&token.token, Token::KeywordNeeds | Token::KeywordWith) + { + self.bump_sync(); // Consume 'needs' / 'with' + parameters = self.parse_parameter_list()?; + } + + // Optional return type: 'requires action get_area: Number' + let return_type = if let Some(token) = self.cursor.peek() + && token.token == Token::Colon + { + self.bump_sync(); // Consume ':' + if let Some(type_token) = self.cursor.peek() { + if let Some(parsed) = colon_type_from_token(&type_token.token) { + self.bump_sync(); // Consume type name + Some(parsed) + } else { + return Err(ParseError::from_token( + "Expected type name after ':' in interface action signature" + .to_string(), + type_token, + )); + } + } else { + return Err(ParseError::from_token( + "Expected type name after ':' in interface action signature" + .to_string(), + requires_token, + )); + } + } else { + None + }; + + required_actions.push(ActionSignature { + name, + parameters, + return_type, + line, + column, + }); + } + _ => { + return Err(ParseError::from_token( + format!( + "Unexpected token in interface body: {:?}. Interface bodies contain 'requires action ' signatures.", + token.token + ), + token, + )); + } + } + } + + Ok(required_actions) + } + fn parse_container_instantiation(&mut self) -> Result { let start_token = self.bump_sync().unwrap(); // Consume 'create' let line = start_token.line; diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index d39e1d34..67cee749 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -6439,6 +6439,7 @@ impl TypeChecker { ); } } + self.check_interface_conformance(_name, implements, *line, *column); for property in properties.iter().chain(static_properties.iter()) { if let Some(default_expr) = &property.default_value { @@ -10044,6 +10045,79 @@ impl TypeChecker { .push(TypeError::new(message, expected, found, line, column)); } + /// Statically verify that a container provides every action required by + /// the interfaces it implements (including requirements accumulated + /// through interface `extends` chains). A requirement may be satisfied by + /// an inherited method from the container's own `extends` chain. Mirrors + /// the interpreter's runtime enforcement so tooling surfaces the breach + /// before execution. + fn check_interface_conformance( + &mut self, + container_name: &str, + implements: &[String], + line: usize, + column: usize, + ) { + use std::collections::HashSet; + + // Resolve a method's parameter count through the container chain. + let find_method_param_count = |analyzer: &Analyzer, method_name: &str| -> Option { + let mut current = Some(container_name.to_string()); + let mut visited = HashSet::new(); + while let Some(name) = current { + if !visited.insert(name.clone()) { + return None; + } + let container = analyzer.get_container(&name)?; + if let Some(method) = container.methods.get(method_name) { + return Some(method.parameters.len()); + } + current = container.extends.clone(); + } + None + }; + + // Collect diagnostics first so the analyzer borrow does not overlap + // the mutable self borrow that type_error needs. + let mut diagnostics = Vec::new(); + for interface_name in implements { + let mut pending = vec![interface_name.clone()]; + let mut visited = HashSet::new(); + while let Some(current_name) = pending.pop() { + if !visited.insert(current_name.clone()) { + continue; + } + // Unknown names were already reported by the caller's + // interface-existence check; skip silently here. + let Some(interface) = self.analyzer.get_interface(¤t_name) else { + continue; + }; + pending.extend(interface.extends.iter().cloned()); + + for (action_name, signature) in &interface.required_actions { + match find_method_param_count(&self.analyzer, action_name) { + None => diagnostics.push(format!( + "Container '{container_name}' does not satisfy interface '{}': missing required action '{action_name}'", + interface.name + )), + Some(count) if count != signature.parameters.len() => { + diagnostics.push(format!( + "Container '{container_name}' does not satisfy interface '{}': action '{action_name}' takes {count} parameter(s) but the interface requires {}", + interface.name, + signature.parameters.len() + )) + } + Some(_) => {} + } + } + } + } + + for message in diagnostics { + self.type_error(message, None, None, line, column); + } + } + /// Recreate a value that the interpreter binds while executing a statement. /// /// The analyzer checks action/loop/handler bodies in temporary scopes and From 8f3a96395c887be84224be276ef5d191c7a73242 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:52:45 +0000 Subject: [PATCH 3/6] test: red tests for PR #686 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the verified reviewer findings before fixing them: - fixer round-trip: --fix output for interface bodies (and bare interfaces) must re-parse (currently emits 'with/as/and', 'returns', 'end interface' — all rejected by the parser) - typechecker must report unknown and non-interface names reached through interface extends chains (currently silently skipped, diverging from runtime enforcement) - typechecker must not emit false missing-action diagnostics when the container's parent chain cannot be resolved statically - typechecker must report interface return-type mismatches - unterminated interface bodies must carry a real source position - regression pins for behavior that is already correct: static actions do not satisfy instance contracts, implementing a container fails, and the parameter/return-type colon boundary Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KEThJZTipEWKQvLxQWxd7R --- tests/interface_contract_test.rs | 365 +++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) diff --git a/tests/interface_contract_test.rs b/tests/interface_contract_test.rs index bb585477..9a79d500 100644 --- a/tests/interface_contract_test.rs +++ b/tests/interface_contract_test.rs @@ -330,6 +330,371 @@ display "should not get here" ); } +// === Static/runtime parity for extends-chain names (PR #686 review) === + +#[test] +fn interface_extending_unknown_interface_fails_at_runtime() { + let program = r#" +create interface Child extends NotAnInterface: + requires action ping +end + +create container Widget implements Child: + property id: Number + + action ping: + display "pong" + end +end + +display "should not get here" +"#; + let output = run_wfl_program(program, "iface_extends_unknown_runtime"); + assert!( + !output.status.success(), + "an interface extending an undefined name must fail when implemented" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("NotAnInterface"), + "error should name the unknown parent interface; got: {stderr}" + ); +} + +#[test] +fn typechecker_reports_unknown_extended_interface() { + // The static check must not silently skip extends-chain names the way it + // does for direct `implements` entries (those are reported separately): + // runtime rejects this program, so `wfl --analyze`-level tooling must too. + use wfl::typechecker::TypeChecker; + + let source = r#" +create interface Child extends NotAnInterface: + requires action ping +end + +create container Widget implements Child: + property id: Number + + action ping: + display "pong" + end +end +"#; + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("test program should parse"); + let diagnostics = TypeChecker::new() + .check_types(&program) + .expect_err("extending an undefined interface must be a static error") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("NotAnInterface")), + "expected a diagnostic naming the unknown parent interface: {diagnostics:?}" + ); +} + +#[test] +fn typechecker_reports_non_interface_extended_name() { + use wfl::typechecker::TypeChecker; + + let source = r#" +create container NotReallyAnInterface: + property id: Number +end + +create interface Child extends NotReallyAnInterface: + requires action ping +end + +create container Widget implements Child: + property id: Number + + action ping: + display "pong" + end +end +"#; + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("test program should parse"); + let diagnostics = TypeChecker::new() + .check_types(&program) + .expect_err("extending a container instead of an interface must be a static error") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("NotReallyAnInterface") + && error.message.contains("not an interface")), + "expected a diagnostic naming the non-interface parent: {diagnostics:?}" + ); +} + +#[test] +fn typechecker_skips_conformance_when_parent_chain_unresolvable() { + // A container whose extends-parent the analyzer cannot resolve (e.g. a + // parent supplied by `include from`) must not produce a false "missing + // required action" diagnostic — the parent may well provide the action. + // The unresolved parent itself is already reported separately. + use wfl::typechecker::TypeChecker; + + let source = r#" +create interface Greeter: + requires action greet +end + +create container Employee extends UnresolvableParent implements Greeter: + property job_title: Text +end +"#; + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("test program should parse"); + let diagnostics = match TypeChecker::new().check_types(&program) { + Ok(()) => Vec::new(), + Err(e) => e.into_diagnostics(), + }; + assert!( + !diagnostics + .iter() + .any(|error| error.message.contains("does not satisfy interface")), + "an unresolvable parent chain must suppress conformance diagnostics, got: {diagnostics:?}" + ); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("UnresolvableParent")), + "the unresolved parent itself should still be reported: {diagnostics:?}" + ); +} + +#[test] +fn typechecker_reports_interface_return_type_mismatch() { + use wfl::typechecker::TypeChecker; + + let source = r#" +create interface Measurable: + requires action get_area: Number +end + +create container Card implements Measurable: + property label: Text + + action get_area: Text + return label + end +end +"#; + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("test program should parse"); + let diagnostics = TypeChecker::new() + .check_types(&program) + .expect_err("a matching-arity action with an incompatible return type must be a static error") + .into_diagnostics(); + assert!( + diagnostics.iter().any(|error| { + error.message.contains("get_area") && error.message.contains("return") + }), + "expected a return-type conformance diagnostic for get_area: {diagnostics:?}" + ); +} + +#[test] +fn static_action_does_not_satisfy_interface() { + // Interface contracts are instance contracts: a static action with the + // right name must not satisfy `requires action`. + let program = r#" +create interface Drawable: + requires action draw +end + +create container Chart implements Drawable: + property title: Text + + static action draw: + display "static draw" + end +end + +display "should not get here" +"#; + let output = run_wfl_program(program, "iface_static_not_satisfying"); + assert!( + !output.status.success(), + "a static action must not satisfy an instance interface requirement" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("draw"), + "error should name the missing instance action; got: {stderr}" + ); +} + +#[test] +fn implementing_a_container_instead_of_an_interface_fails_at_runtime() { + let program = r#" +create container NotAnInterface: + property id: Number +end + +create container Widget implements NotAnInterface: + property id: Number +end + +display "should not get here" +"#; + let output = run_wfl_program(program, "iface_not_an_interface"); + assert!( + !output.status.success(), + "implementing a non-interface must fail" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("is not an interface"), + "error should explain the target is not an interface; got: {stderr}" + ); +} + +#[test] +fn typed_parameter_and_return_type_colon_boundary() { + // Pin the colon grammar for interface signatures: the first colon after a + // parameter name annotates the parameter; a further colon sets the + // required return type. + let source = r#" +create interface Sizer: + requires action set_size needs value: Number + requires action scaled_size needs factor: Number: Number +end +"#; + let program = parse(source).expect("both colon forms should parse"); + let stmt = program + .statements + .iter() + .find(|s| matches!(s, Statement::InterfaceDefinition { .. })) + .expect("expected an InterfaceDefinition statement"); + if let Statement::InterfaceDefinition { + required_actions, .. + } = stmt + { + let set_size = required_actions + .iter() + .find(|a| a.name == "set_size") + .expect("set_size signature"); + assert_eq!(set_size.parameters.len(), 1); + assert!( + set_size.parameters[0].param_type.is_some(), + "first colon annotates the parameter" + ); + assert!( + set_size.return_type.is_none(), + "no second colon means no required return type" + ); + + let scaled = required_actions + .iter() + .find(|a| a.name == "scaled_size") + .expect("scaled_size signature"); + assert_eq!(scaled.parameters.len(), 1); + assert!(scaled.parameters[0].param_type.is_some()); + assert!( + scaled.return_type.is_some(), + "second colon sets the required return type" + ); + } +} + +#[test] +fn unterminated_interface_body_reports_real_position() { + let source = "create interface Broken:\n requires action draw\n"; + let errors = parse(source).expect_err("missing 'end' must be a parse error"); + assert!( + errors + .iter() + .any(|e| e.line > 0 && e.message.contains("interface body")), + "the unterminated-body diagnostic should carry a real source position: {errors:?}" + ); +} + +// === Fixer round-trip (PR #686 review): --fix output must re-parse === + +#[test] +fn fixer_roundtrip_preserves_interface_bodies() { + use wfl::fixer::CodeFixer; + + let source = r#"create interface Drawable: + requires action draw + requires action get_area: Number + requires action resize needs w: Number, h: Number +end +"#; + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("interface body should parse"); + + let (fixed_code, _) = CodeFixer::new().fix(&program, source); + + let fixed_tokens = lex_wfl_with_positions(&fixed_code); + let mut fixed_parser = Parser::new(&fixed_tokens); + let reparsed = fixed_parser + .parse() + .unwrap_or_else(|e| panic!("fixer output must re-parse, got {e:?}\noutput:\n{fixed_code}")); + + let stmt = reparsed + .statements + .iter() + .find(|s| matches!(s, Statement::InterfaceDefinition { .. })) + .expect("re-parsed program should still contain the interface"); + if let Statement::InterfaceDefinition { + required_actions, .. + } = stmt + { + assert_eq!( + required_actions.len(), + 3, + "all required actions must survive the fix round-trip:\n{fixed_code}" + ); + let resize = required_actions + .iter() + .find(|a| a.name == "resize") + .expect("resize survives round-trip"); + assert_eq!(resize.parameters.len(), 2); + let get_area = required_actions + .iter() + .find(|a| a.name == "get_area") + .expect("get_area survives round-trip"); + assert!(get_area.return_type.is_some()); + } +} + +#[test] +fn fixer_roundtrip_preserves_bare_interfaces() { + use wfl::fixer::CodeFixer; + + let source = "create interface Marker\n"; + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("bare interface should parse"); + + let (fixed_code, _) = CodeFixer::new().fix(&program, source); + + let fixed_tokens = lex_wfl_with_positions(&fixed_code); + let mut fixed_parser = Parser::new(&fixed_tokens); + let reparsed = fixed_parser.parse().unwrap_or_else(|e| { + panic!("fixer output for a bare interface must re-parse, got {e:?}\noutput:\n{fixed_code}") + }); + assert!( + reparsed + .statements + .iter() + .any(|s| matches!(s, Statement::InterfaceDefinition { .. })), + "bare interface must survive the fix round-trip:\n{fixed_code}" + ); +} + #[test] fn bare_interface_accepts_any_implementer_at_runtime() { // Backward compatibility: existing programs use `create interface X` with From 87fcb2699515c449358e75d984d330359a57acc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 17:03:20 +0000 Subject: [PATCH 4/6] fix: address PR #686 review findings on interface contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fixer: emit the shipped interface grammar (bare 'end', 'needs a: T, b: T' parameters, ': ReturnType') so --lint --fix output re-parses; bare interfaces are spelled without a colon or body - typechecker: report unknown and non-interface names reached through interface extends chains instead of silently skipping them (parity with runtime enforcement) - typechecker: suppress conformance diagnostics when the container's parent chain cannot be resolved statically (e.g. include-provided parents) — the runtime check remains authoritative, so no false 'missing required action' reports - typechecker: check required return types after method return-type refinement; Unknown/Any stay permissive per gradual typing - interpreter: extends-chain errors say 'required through interface X' rather than claiming the container implements the parent directly - parser: unterminated interface bodies report the last seen position and the interface name instead of 0:0 - manifest: expected_failure_layer now admits layer 5 (runtime) and the interface enforcement error example declares it; README updated - docs: note that contracts are instance contracts (static actions do not satisfy them) and that required return types are checked statically Red evidence: the preceding test-only commit adds seven failing tests (fixer round-trips, extends-chain validation, unresolvable-parent suppression, return-type mismatch, EOF position) plus regression pins for already-correct behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KEThJZTipEWKQvLxQWxd7R --- Docs/04-advanced-features/containers-oop.md | 9 ++ ...2026-08-13-interface-contracts-enforced.md | 32 +++++++ TestPrograms/docs_examples/README.md | 2 +- .../docs_examples/_meta/manifest.json | 7 +- src/fixer/mod.rs | 63 +++++++------ src/interpreter/mod.rs | 18 +++- src/parser/stmt/containers.rs | 26 ++++-- src/typechecker/mod.rs | 92 +++++++++++++++---- tests/interface_contract_test.rs | 4 +- 9 files changed, 192 insertions(+), 61 deletions(-) diff --git a/Docs/04-advanced-features/containers-oop.md b/Docs/04-advanced-features/containers-oop.md index 3053b253..014caa9f 100644 --- a/Docs/04-advanced-features/containers-oop.md +++ b/Docs/04-advanced-features/containers-oop.md @@ -290,6 +290,15 @@ A required action with parameters must be implemented with the same number of parameters. A requirement may also be satisfied by an action inherited from a parent container (`extends`). +Two details of the contract: + +- **Interface contracts are instance contracts.** A `static action` with the + right name does not satisfy `requires action` — the requirement must be met + by a regular (instance) action. +- **Required return types are checked statically.** If an interface declares + `requires action get_area: Number` and the implementing action returns + `Text`, the static checker reports the mismatch before the program runs. + ### Interface Inheritance Interfaces can extend other interfaces; the requirements accumulate: diff --git a/History/dev-diary/2026/2026-08-13-interface-contracts-enforced.md b/History/dev-diary/2026/2026-08-13-interface-contracts-enforced.md index 92a2c6d8..bcb894c8 100644 --- a/History/dev-diary/2026/2026-08-13-interface-contracts-enforced.md +++ b/History/dev-diary/2026/2026-08-13-interface-contracts-enforced.md @@ -77,3 +77,35 @@ the error a breach produces, interface inheritance, and marker interfaces. The keyword references had two stale examples (`define interface called Runnable:`, `requires method run`) that matched no grammar past or present; both now show the real forms. + +## Review follow-up (same day) + +Automated reviewers on PR #686 surfaced real gaps, fixed red-first in a +follow-up commit: + +- **Fixer round-trip** — `wfl --lint --fix --in-place` rewrote interface + declarations into a grammar the parser rejects (`with a as T and b as T`, + `returns`, `end interface`); the fixer now emits the shipped grammar and + spells bare interfaces without a body. +- **Static/runtime parity on extends chains** — the type checker silently + skipped unknown or non-interface names reached through interface `extends`, + so `create interface Child extends NotAnInterface` passed static checks and + only failed at runtime. It reports them now. +- **No false positives on unresolvable parents** — a container whose + `extends` parent the analyzer cannot see (e.g. from `include from`) no + longer draws a bogus "missing required action" diagnostic; conformance + defers to the runtime check, which resolves parents from the live + environment. +- **Return-type conformance** — `requires action get_area: Number` is now + checked statically against the implementing action's declared or inferred + return type (Unknown/Any stay permissive, per gradual typing). +- **Diagnostics** — chain errors name the interface the container actually + implements; an unterminated interface body reports a real position instead + of 0:0. +- **Manifest schema** — `expected_failure_layer` now admits 5 (runtime), and + the enforcement error example declares it. + +Deliberately not changed: the analyzer's interface registry is keyed by name +(not lexical binding), matching the existing container registry; a nested +shadowing interface could confuse static diagnostics, but the runtime check +scopes correctly. A scoped registry for both is a candidate follow-up. diff --git a/TestPrograms/docs_examples/README.md b/TestPrograms/docs_examples/README.md index f6964dc3..26694d87 100644 --- a/TestPrograms/docs_examples/README.md +++ b/TestPrograms/docs_examples/README.md @@ -131,7 +131,7 @@ Partial code demonstrating a concept: ### 3. Error Example (`type: "error_example"`) Intentionally incorrect code to demonstrate errors: - Must fail at specified layer -- Requires `expected_failure_layer` (1-4) +- Requires `expected_failure_layer` (1-5; 5 = runtime execution) - Requires `expected_error_pattern` (regex) - Used for: Error handling docs, type safety demos diff --git a/TestPrograms/docs_examples/_meta/manifest.json b/TestPrograms/docs_examples/_meta/manifest.json index 8706fa60..188c0176 100644 --- a/TestPrograms/docs_examples/_meta/manifest.json +++ b/TestPrograms/docs_examples/_meta/manifest.json @@ -65,9 +65,9 @@ }, "expected_failure_layer": { "type": "integer", - "description": "For error_example type: which layer should fail (1-4)", + "description": "For error_example type: which layer should fail (1-5; 5 = runtime execution)", "minimum": 1, - "maximum": 4 + "maximum": 5 }, "expected_error_pattern": { "type": "string", @@ -560,7 +560,8 @@ "interfaces", "error" ], - "doc_purpose": "Shows that a container missing a required interface action is rejected at runtime" + "doc_purpose": "Shows that a container missing a required interface action is rejected at runtime", + "expected_failure_layer": 5 }, "docs_examples/containers/task_manager_01.wfl": { "doc_section": "Docs/04-advanced-features/containers-oop.md#complete-example-task-manager", diff --git a/src/fixer/mod.rs b/src/fixer/mod.rs index 76ab5298..b7ce983b 100644 --- a/src/fixer/mod.rs +++ b/src/fixer/mod.rs @@ -722,41 +722,48 @@ impl CodeFixer { output.push_str(&extends.join(", ")); } - output.push_str(":\n"); - - // Format required actions - for action in required_actions { - output.push_str(&format!("{indent} ")); - output.push_str("requires action "); - output.push_str(&action.name); + // A bare interface (no requirements) is spelled without a + // colon or 'end' — the parser only opens a body after ':'. + if required_actions.is_empty() { + output.push('\n'); + summary.lines_reformatted += 1; + } else { + output.push_str(":\n"); - // Format parameters - if !action.parameters.is_empty() { - output.push_str(" with "); - for (i, param) in action.parameters.iter().enumerate() { - if i > 0 { - output.push_str(" and "); - } - output.push_str(¶m.name); - if let Some(param_type) = ¶m.param_type { - output.push_str(" as "); - output.push_str(&self.format_type(param_type)); + // Format required actions in the grammar the parser + // accepts: 'requires action [needs a: T, b: T] + // [: ReturnType]'. + for action in required_actions { + output.push_str(&format!("{indent} ")); + output.push_str("requires action "); + output.push_str(&action.name); + + if !action.parameters.is_empty() { + output.push_str(" needs "); + for (i, param) in action.parameters.iter().enumerate() { + if i > 0 { + output.push_str(", "); + } + output.push_str(¶m.name); + if let Some(param_type) = ¶m.param_type { + output.push_str(": "); + output.push_str(&self.format_type(param_type)); + } } } - } - // Format return type - if let Some(return_type) = &action.return_type { - output.push_str(" returns "); - output.push_str(&self.format_type(return_type)); + if let Some(return_type) = &action.return_type { + output.push_str(": "); + output.push_str(&self.format_type(return_type)); + } + + output.push('\n'); } - output.push('\n'); + output.push_str(&indent); + output.push_str("end\n"); + summary.lines_reformatted += 1; } - - output.push_str(&indent); - output.push_str("end interface\n"); - summary.lines_reformatted += 1; } Statement::EventDefinition { name, parameters, .. diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 59bbf3ae..26af109d 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -15387,18 +15387,26 @@ impl Interpreter { }; for interface_name in implements { - let mut pending = vec![interface_name.clone()]; + // (name, reached-through-extends): parent interfaces are named in + // errors together with the interface the container actually + // implements, so the message doesn't claim a direct 'implements'. + let mut pending = vec![(interface_name.clone(), false)]; let mut visited = HashSet::new(); - while let Some(current_name) = pending.pop() { + while let Some((current_name, inherited)) = pending.pop() { if !visited.insert(current_name.clone()) { continue; } + let via = if inherited { + format!(" (required through interface '{interface_name}')") + } else { + String::new() + }; let interface = match env.borrow().get(¤t_name) { Some(Value::InterfaceDefinition(definition)) => definition, Some(_) => { return Err(RuntimeError::new( format!( - "Container '{container_name}' implements '{current_name}', but '{current_name}' is not an interface" + "Container '{container_name}' requires '{current_name}'{via}, but '{current_name}' is not an interface" ), line, column, @@ -15407,7 +15415,7 @@ impl Interpreter { None => { return Err(RuntimeError::new( format!( - "Interface '{current_name}' not found for container '{container_name}'" + "Interface '{current_name}' not found for container '{container_name}'{via}" ), line, column, @@ -15416,7 +15424,7 @@ impl Interpreter { }; for parent in &interface.extends { - pending.push(parent.clone()); + pending.push((parent.clone(), true)); } for (action_name, signature) in &interface.required_actions { diff --git a/src/parser/stmt/containers.rs b/src/parser/stmt/containers.rs index c28dd8c2..b29e44aa 100644 --- a/src/parser/stmt/containers.rs +++ b/src/parser/stmt/containers.rs @@ -15,7 +15,12 @@ pub(crate) trait ContainerParser<'a>: ExprParser<'a> + ActionParser<'a> { Self: StmtParser<'a>; fn parse_interface_definition(&mut self) -> Result; - fn parse_interface_body(&mut self) -> Result, ParseError>; + fn parse_interface_body( + &mut self, + interface_name: &str, + header_line: usize, + header_column: usize, + ) -> Result, ParseError>; fn parse_container_instantiation(&mut self) -> Result; fn parse_event_definition(&mut self) -> Result; fn parse_event_trigger(&mut self) -> Result; @@ -226,7 +231,7 @@ impl<'a> ContainerParser<'a> for Parser<'a> { && token.token == Token::Colon { self.bump_sync(); // Consume ':' - required_actions = self.parse_interface_body()?; + required_actions = self.parse_interface_body(&name, line, column)?; } Ok(Statement::InterfaceDefinition { @@ -238,18 +243,27 @@ impl<'a> ContainerParser<'a> for Parser<'a> { }) } - fn parse_interface_body(&mut self) -> Result, ParseError> { + fn parse_interface_body( + &mut self, + interface_name: &str, + header_line: usize, + header_column: usize, + ) -> Result, ParseError> { let mut required_actions = Vec::new(); + let mut last_position = (header_line, header_column); loop { let Some(token) = self.cursor.peek() else { return Err(ParseError::from_span( - "Unexpected end of input in interface body".to_string(), + format!( + "Unexpected end of input in interface body: interface '{interface_name}' is missing 'end'" + ), crate::diagnostics::Span { start: 0, end: 0 }, - 0, - 0, + last_position.0, + last_position.1, )); }; + last_position = (token.line, token.column); match &token.token { Token::KeywordEnd => { diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 67cee749..020bee0e 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -6439,7 +6439,6 @@ impl TypeChecker { ); } } - self.check_interface_conformance(_name, implements, *line, *column); for property in properties.iter().chain(static_properties.iter()) { if let Some(default_expr) = &property.default_value { @@ -6585,6 +6584,11 @@ impl TypeChecker { } } + // Conformance runs after the return-type refinement above so + // unannotated methods are checked against their real inferred + // return types rather than the provisional Unknown. + self.check_interface_conformance(_name, implements, *line, *column); + // Runtime returns the newly registered container definition. self.current_statement_completion = Type::Container(_name.clone()); } @@ -10060,54 +10064,108 @@ impl TypeChecker { ) { use std::collections::HashSet; - // Resolve a method's parameter count through the container chain. - let find_method_param_count = |analyzer: &Analyzer, method_name: &str| -> Option { + /// How a required action resolved against the container chain. + enum MethodLookup { + Found { + param_count: usize, + return_type: Type, + }, + Missing, + /// A parent in the container's `extends` chain is invisible to + /// the analyzer (e.g. supplied by `include from`); conformance + /// cannot be decided statically, so no diagnostic is emitted — + /// the runtime check remains authoritative. + ChainUnresolved, + } + + let find_method = |analyzer: &Analyzer, method_name: &str| -> MethodLookup { let mut current = Some(container_name.to_string()); let mut visited = HashSet::new(); while let Some(name) = current { if !visited.insert(name.clone()) { - return None; + // Definition cycle: reported by the inheritance checks. + return MethodLookup::Missing; } - let container = analyzer.get_container(&name)?; + let Some(container) = analyzer.get_container(&name) else { + return MethodLookup::ChainUnresolved; + }; if let Some(method) = container.methods.get(method_name) { - return Some(method.parameters.len()); + return MethodLookup::Found { + param_count: method.parameters.len(), + return_type: method.return_type.clone(), + }; } current = container.extends.clone(); } - None + MethodLookup::Missing }; // Collect diagnostics first so the analyzer borrow does not overlap // the mutable self borrow that type_error needs. let mut diagnostics = Vec::new(); for interface_name in implements { - let mut pending = vec![interface_name.clone()]; + // (name, reached-through-extends) — direct `implements` entries + // have their existence reported by the caller already; names + // reached through interface `extends` chains are validated here. + let mut pending = vec![(interface_name.clone(), false)]; let mut visited = HashSet::new(); - while let Some(current_name) = pending.pop() { + while let Some((current_name, inherited)) = pending.pop() { if !visited.insert(current_name.clone()) { continue; } - // Unknown names were already reported by the caller's - // interface-existence check; skip silently here. let Some(interface) = self.analyzer.get_interface(¤t_name) else { + if inherited { + if self.analyzer.get_symbol(¤t_name).is_some() { + diagnostics.push(format!( + "Container '{container_name}' requires '{current_name}' through interface '{interface_name}', but '{current_name}' is not an interface" + )); + } else { + diagnostics.push(format!( + "Interface '{current_name}' not found for container '{container_name}' (required through interface '{interface_name}')" + )); + } + } continue; }; - pending.extend(interface.extends.iter().cloned()); + pending.extend( + interface + .extends + .iter() + .map(|parent| (parent.clone(), true)), + ); for (action_name, signature) in &interface.required_actions { - match find_method_param_count(&self.analyzer, action_name) { - None => diagnostics.push(format!( + match find_method(&self.analyzer, action_name) { + MethodLookup::Missing => diagnostics.push(format!( "Container '{container_name}' does not satisfy interface '{}': missing required action '{action_name}'", interface.name )), - Some(count) if count != signature.parameters.len() => { + MethodLookup::Found { param_count, .. } + if param_count != signature.parameters.len() => + { diagnostics.push(format!( - "Container '{container_name}' does not satisfy interface '{}': action '{action_name}' takes {count} parameter(s) but the interface requires {}", + "Container '{container_name}' does not satisfy interface '{}': action '{action_name}' takes {param_count} parameter(s) but the interface requires {}", interface.name, signature.parameters.len() )) } - Some(_) => {} + MethodLookup::Found { return_type, .. } => { + // Compare return types only when both sides are + // concrete — Unknown (no annotation / unrefined) + // and Any stay permissive, matching the + // gradual-typing rules. + let required_return = &signature.return_type; + if !matches!(required_return, Type::Unknown | Type::Any) + && !matches!(return_type, Type::Unknown | Type::Any) + && !self.are_types_compatible(required_return, &return_type) + { + diagnostics.push(format!( + "Container '{container_name}' does not satisfy interface '{}': action '{action_name}' returns {return_type} but the interface requires {required_return}", + interface.name + )); + } + } + MethodLookup::ChainUnresolved => {} } } } diff --git a/tests/interface_contract_test.rs b/tests/interface_contract_test.rs index 9a79d500..6a66bc00 100644 --- a/tests/interface_contract_test.rs +++ b/tests/interface_contract_test.rs @@ -493,7 +493,9 @@ end let program = parser.parse().expect("test program should parse"); let diagnostics = TypeChecker::new() .check_types(&program) - .expect_err("a matching-arity action with an incompatible return type must be a static error") + .expect_err( + "a matching-arity action with an incompatible return type must be a static error", + ) .into_diagnostics(); assert!( diagnostics.iter().any(|error| { From 62e32a9d501b27f47acc619ce8273bbad3cbcd91 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 00:51:46 +0000 Subject: [PATCH 5/6] fix: CI-SKIP the intentional interface error docs example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Run WFL Programs' CI job executes every .wfl under TestPrograms/ and only knows two expected-failure mechanisms: the error_examples/ directory and a first-line '// CI-SKIP:' directive. The new docs_examples/containers/interface_missing_action_01.wfl exits 1 by design (it demonstrates interface-contract enforcement) and sits in neither bucket, so the job failed on it (150 passed, 1 failed). Mark it with the CI-SKIP directive per testing.md §8.2 — it is still executed and asserted (expected_exit_code 1, error pattern match) by scripts/validate_docs_examples.py, so no coverage is lost. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KEThJZTipEWKQvLxQWxd7R --- .../docs_examples/containers/interface_missing_action_01.wfl | 1 + 1 file changed, 1 insertion(+) diff --git a/TestPrograms/docs_examples/containers/interface_missing_action_01.wfl b/TestPrograms/docs_examples/containers/interface_missing_action_01.wfl index 77537b84..3ce9a735 100644 --- a/TestPrograms/docs_examples/containers/interface_missing_action_01.wfl +++ b/TestPrograms/docs_examples/containers/interface_missing_action_01.wfl @@ -1,3 +1,4 @@ +// CI-SKIP: intentional interface-contract error example (exits 1 by design; executed and asserted by scripts/validate_docs_examples.py with expected_exit_code 1) create interface Drawable: requires action draw end From 070be338b1852f512301e54a5d02a14a7d33f55d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 01:07:40 +0000 Subject: [PATCH 6/6] fix: fixer emits parseable container bodies; keep contract names in sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to a CodeRabbit finding on PR #686: the fixer normalized the implementing action's name but not the interface requirement, so fixing a program with a camelCase action broke its own contract. The red test for that scenario exposed a wider problem: the fixer's container arm emitted grammar the container-body parser rejects entirely — 'define action called ... end action' methods, 'end container', 'static property x as T = v', and 'event e with a as T and b as T'. - container methods now print in container-body grammar via a dedicated printer: 'action [needs a: T, b: T][: ReturnType]:' + body + 'end'; containers close with 'end' - properties print ': Type' and 'defaults '; events print 'needs a: T, b: T' - names on the container/interface surface (methods, requirements, properties, events) are deliberately NOT snake_case-normalized: method-call sites, property initializers, and member accesses print the original spelling, so renaming only definitions would break the fixed program. This keeps requirement and implementation names in sync (the reviewer's scenario) without desyncing call sites — the opposite direction from the suggested one-line fix, for that reason. End-to-end: '--lint --fix --in-place' on containers_comprehensive.wfl and interface_contracts.wfl now produces programs that re-parse and run to completion. Red evidence: fixer_normalizes_requirement_and_implementation_names_together failed against the prior fixer (its output did not even re-parse). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KEThJZTipEWKQvLxQWxd7R --- src/fixer/mod.rs | 108 +++++++++++++++++++++++++++---- tests/interface_contract_test.rs | 62 ++++++++++++++++++ 2 files changed, 157 insertions(+), 13 deletions(-) diff --git a/src/fixer/mod.rs b/src/fixer/mod.rs index b7ce983b..76ca957a 100644 --- a/src/fixer/mod.rs +++ b/src/fixer/mod.rs @@ -478,19 +478,22 @@ impl CodeFixer { output.push_str(":\n"); - // Format static properties + // Format static properties. Property names are deliberately + // NOT snake_case-normalized: initializers in 'create new' + // blocks and member accesses print the original spelling, so + // renaming only the definition would break the program. for prop in static_properties { output.push_str(&format!("{indent} ")); output.push_str("static property "); output.push_str(&prop.name); if let Some(prop_type) = &prop.property_type { - output.push_str(" as "); + output.push_str(": "); output.push_str(&self.format_type(prop_type)); } if let Some(default) = &prop.default_value { - output.push_str(" = "); + output.push_str(" defaults "); self.pretty_print_expression(default, output, indent_level + 1, summary); } @@ -512,7 +515,7 @@ impl CodeFixer { } if let Some(default) = &prop.default_value { - output.push_str(" = "); + output.push_str(" defaults "); self.pretty_print_expression(default, output, indent_level + 1, summary); } @@ -611,21 +614,22 @@ impl CodeFixer { output.push('\n'); } - // Format events + // Format events in the grammar the container-body parser + // accepts: 'event [needs a: T, b: T]'. for event in events { output.push_str(&format!("{indent} ")); output.push_str("event "); output.push_str(&event.name); if !event.parameters.is_empty() { - output.push_str(" with "); + output.push_str(" needs "); for (i, param) in event.parameters.iter().enumerate() { if i > 0 { - output.push_str(" and "); + output.push_str(", "); } output.push_str(¶m.name); if let Some(param_type) = ¶m.param_type { - output.push_str(" as "); + output.push_str(": "); output.push_str(&self.format_type(param_type)); } } @@ -641,18 +645,31 @@ impl CodeFixer { output.push('\n'); } - // Format static methods + // Format methods in container-body grammar ('action + // ...: ... end'), not the standalone 'define action called' + // form, which the container-body parser rejects. for method in static_methods { - self.pretty_print_statement(method, output, indent_level + 1, summary); + self.pretty_print_container_action( + method, + output, + indent_level + 1, + true, + summary, + ); } - // Format instance methods for method in methods { - self.pretty_print_statement(method, output, indent_level + 1, summary); + self.pretty_print_container_action( + method, + output, + indent_level + 1, + false, + summary, + ); } output.push_str(&indent); - output.push_str("end container\n"); + output.push_str("end\n"); summary.lines_reformatted += 1; } Statement::ContainerInstantiation { @@ -1016,6 +1033,71 @@ impl CodeFixer { } } + /// Print a container method in the grammar the container-body parser + /// accepts: `action [needs a: T, b: T][: ReturnType]:` + body + + /// `end`. Method and parameter names are deliberately NOT snake_case + /// normalized — method-call sites and interface `requires action` names + /// print the original spelling, so renaming only the definition would + /// break the fixed program (calls and interface conformance alike). + fn pretty_print_container_action( + &self, + method: &Statement, + output: &mut String, + indent_level: usize, + is_static: bool, + summary: &mut FixerSummary, + ) { + let Statement::ActionDefinition { + name, + parameters, + body, + return_type, + .. + } = method + else { + return; + }; + + let indent = " ".repeat(indent_level); + output.push_str(&indent); + if is_static { + output.push_str("static "); + } + output.push_str("action "); + output.push_str(name); + + if !parameters.is_empty() { + output.push_str(" needs "); + for (i, param) in parameters.iter().enumerate() { + if i > 0 { + output.push_str(", "); + } + output.push_str(¶m.name); + if let Some(param_type) = ¶m.param_type { + output.push_str(": "); + output.push_str(&self.format_type(param_type)); + } + } + } + + // The colon doubles as the body marker; a return type follows it + // directly ('action get_area: Number'), matching the parser. + output.push(':'); + if let Some(return_type) = return_type { + output.push(' '); + output.push_str(&self.format_type(return_type)); + } + output.push('\n'); + + for statement in body { + self.pretty_print_statement(statement, output, indent_level + 1, summary); + } + + output.push_str(&indent); + output.push_str("end\n"); + summary.lines_reformatted += 1; + } + fn fix_identifier_name(&self, name: &str, summary: &mut FixerSummary) -> String { if !self.is_snake_case(name) { summary.vars_renamed += 1; diff --git a/tests/interface_contract_test.rs b/tests/interface_contract_test.rs index 6a66bc00..4ab37013 100644 --- a/tests/interface_contract_test.rs +++ b/tests/interface_contract_test.rs @@ -672,6 +672,68 @@ end } } +#[test] +fn fixer_normalizes_requirement_and_implementation_names_together() { + // The fixer snake_cases action names. If it normalized the implementing + // action but not the interface requirement, the fixed program would no + // longer satisfy its own contract. Both sides must agree after a fix. + use wfl::fixer::CodeFixer; + + let source = r#"create interface Drawable: + requires action drawShape +end + +create container Sketch implements Drawable: + property title: Text + + action drawShape: + display "drawing" + end +end +"#; + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser + .parse() + .expect("camelCase interface program should parse"); + + let (fixed_code, _) = CodeFixer::new().fix(&program, source); + + let fixed_tokens = lex_wfl_with_positions(&fixed_code); + let mut fixed_parser = Parser::new(&fixed_tokens); + let reparsed = fixed_parser + .parse() + .unwrap_or_else(|e| panic!("fixer output must re-parse, got {e:?}\noutput:\n{fixed_code}")); + + let required_name = reparsed + .statements + .iter() + .find_map(|s| match s { + Statement::InterfaceDefinition { + required_actions, .. + } => required_actions.first().map(|a| a.name.clone()), + _ => None, + }) + .expect("requirement survives the fix round-trip"); + let method_name = reparsed + .statements + .iter() + .find_map(|s| match s { + Statement::ContainerDefinition { methods, .. } => { + methods.iter().find_map(|m| match m { + Statement::ActionDefinition { name, .. } => Some(name.clone()), + _ => None, + }) + } + _ => None, + }) + .expect("implementation survives the fix round-trip"); + assert_eq!( + required_name, method_name, + "requirement and implementation must stay in sync after fixing:\n{fixed_code}" + ); +} + #[test] fn fixer_roundtrip_preserves_bare_interfaces() { use wfl::fixer::CodeFixer;