From d9502fffd25ce7ad9d73eab3e6eb878c70667e9c Mon Sep 17 00:00:00 2001 From: nullhack Date: Thu, 7 May 2026 02:16:50 -0400 Subject: [PATCH 1/4] docs(robustness): planning artifacts for export robustness fixes - Post-mortems: cross-adapter flags, empty directory, YAML traceback leak - Feature file: 6 BDD scenarios across 3 rules - Interview notes, event storming addendum, test stubs --- docs/features/export-robustness.feature | 80 +++++++++++++++++++ .../IN_20260507_export-robustness.md | 38 +++++++++ .../PM_20260507_cross-adapter-flags.md | 24 ++++++ .../PM_20260507_empty-directory-silent.md | 21 +++++ .../PM_20260507_yaml-traceback-leak.md | 21 +++++ docs/spec/event_storming.md | 66 +++++++++++++++ tests/features/export-robustness/__init__.py | 1 + .../export_robustness_test.py | 63 +++++++++++++++ 8 files changed, 314 insertions(+) create mode 100644 docs/features/export-robustness.feature create mode 100644 docs/interview-notes/IN_20260507_export-robustness.md create mode 100644 docs/post-mortem/PM_20260507_cross-adapter-flags.md create mode 100644 docs/post-mortem/PM_20260507_empty-directory-silent.md create mode 100644 docs/post-mortem/PM_20260507_yaml-traceback-leak.md create mode 100644 tests/features/export-robustness/__init__.py create mode 100644 tests/features/export-robustness/export_robustness_test.py diff --git a/docs/features/export-robustness.feature b/docs/features/export-robustness.feature new file mode 100644 index 0000000..2eebb75 --- /dev/null +++ b/docs/features/export-robustness.feature @@ -0,0 +1,80 @@ +Feature: Export Robustness + + Fixes three post-PR edge cases in the export command and CLI error handling: + warns on unused adapter flags, rejects empty directories with exit code 1, + and catches malformed YAML with user-friendly errors across all commands. + + Status: BASELINED (2026-05-07) + + Rules (Business): + - When a user passes an adapter flag that the selected format does not consume, the CLI prints a warning to stderr listing the unused flag names + - When a user exports from a directory containing no YAML flow files, the CLI prints an error to stderr and exits with code 1 + - When a user passes a malformed YAML file to any CLI command (validate, export, states, check, next, transition, session), the CLI prints a single-line error to stderr with no traceback and exits with code 1 + + Constraints: + - No new runtime dependencies + - Error messages follow ADR_20260426_cli_io_convention (stderr for errors/warnings) + - Exit codes follow ADR_20260426_cli_io_convention (0 = success, 1 = command failed) + + ## Questions + + | ID | Question | Status | Answer / Assumption | + |----|----------|--------|---------------------| + | Q1 | Should the unused-flag warning list flag names or just count? | Resolved | List flag names for clarity | + + ## Changes + + | Session | Q-IDs | Change | + |---------|-------|--------| + | 2026-05-07 IN_20260507 | — | Created: robustness fixes from post-PR adversarial dry-run | + + Rule: Unused adapter flag warning + As a CLI user + I want to be warned when I pass a flag irrelevant to my selected export format + So that I don't silently get unexpected output + + @id:a1b2c3d4 + Example: JSON format with --no-conditions flag + Given a flow definition file exists at `examples/simple.yaml` + When the user runs `flowr export --format json --no-conditions examples/simple.yaml` + Then the command prints a warning to stderr containing "no-conditions" and exits with code 0 + + @id:e5f6a7b8 + Example: Mermaid format with --flat flag + Given a flow definition file exists at `examples/simple.yaml` + When the user runs `flowr export --format mermaid --flat examples/simple.yaml` + Then the command prints a warning to stderr containing "flat" and exits with code 0 + + Rule: Empty directory rejection + As a CLI user + I want the export command to reject an empty directory with a clear error + So that I know no flows were found instead of silently getting empty output + + @id:c9d0e1f2 + Example: Export from empty directory + Given a directory exists at `/tmp/empty_flows` with no YAML files + When the user runs `flowr export --format json /tmp/empty_flows` + Then the command prints an error to stderr stating no flow files were found and exits with code 1 + + @id:a3b4c5d6 + Example: Export from directory with only non-YAML files + Given a directory exists at `/tmp/mixed` containing only `.txt` and `.json` files + When the user runs `flowr export --format json /tmp/mixed` + Then the command prints an error to stderr stating no flow files were found and exits with code 1 + + Rule: Malformed YAML error handling + As a CLI user + I want malformed YAML files to produce a clean error message + So that I never see a raw Python traceback from any command + + @id:e7f8a9b0 + Example: Malformed YAML with export command + Given a file at `/tmp/bad.yaml` contains invalid YAML syntax + When the user runs `flowr export --format json /tmp/bad.yaml` + Then the command prints a single-line error to stderr with no traceback and exits with code 1 + + @id:c1d2e3f4 + Example: Malformed YAML with validate command + Given a file at `/tmp/bad.yaml` contains invalid YAML syntax + When the user runs `flowr validate /tmp/bad.yaml` + Then the command prints a single-line error to stderr with no traceback and exits with code 1 diff --git a/docs/interview-notes/IN_20260507_export-robustness.md b/docs/interview-notes/IN_20260507_export-robustness.md new file mode 100644 index 0000000..be77a75 --- /dev/null +++ b/docs/interview-notes/IN_20260507_export-robustness.md @@ -0,0 +1,38 @@ +# IN_20260507_export-robustness: Post-PR robustness fixes for export and CLI error handling + +## Pain Points + +1. **Cross-adapter flag confusion** — `flowr export --format mermaid --flat` silently ignores `--flat`. Users may believe the flag had an effect. All adapter flags visible in help regardless of selected format. +2. **Empty directory silent success** — `flowr export --format json /tmp/empty` returns `[]` with exit code 0. No indication that no flows were found. Masks user mistakes (wrong directory). +3. **YAML parse traceback leak** — Malformed YAML crashes the CLI with a full Python traceback across all commands (validate, export, states, check, next, transition). Pre-existing defect predating the export feature. + +## Business Goals + +1. Improve CLI reliability — users should never see raw Python tracebacks +2. Clear feedback — every CLI invocation should produce unambiguous output about what happened +3. Consistent error handling — all commands handle malformed input gracefully + +## Terms to Define + +No new domain terms needed — these are edge-case fixes within existing concepts (Export Adapter, Export Registry, Format Resolution). + +## Quality Attributes + +| ID | Attribute | Scenario | Target | Priority | +|----|-----------|----------|--------|----------| +| QA1 | Usability | When a user passes a flag irrelevant to the selected export format, the CLI warns them | Warning on stderr listing unused flag(s) | Should | +| QA2 | Correctness | When a user exports from an empty directory, the CLI reports failure | Error message on stderr, exit code 1 | Must | +| QA3 | Reliability | When a user passes a malformed YAML file to any CLI command, the CLI produces a user-friendly error | Single-line error on stderr, no traceback, exit code 1 | Must | + +## Scope Confirmation + +- **Cross-adapter flags:** Warn on unused flags (stderr warning listing irrelevant flag names) +- **Empty directory:** Error message + exit code 1 +- **YAML traceback:** Fix across all commands (add `yaml.YAMLError` catch in `main()`) +- **Not in scope:** Changing flag registration, two-pass argparse, new domain types + +## Resolved Decisions + +- Format: warn on unused adapter flags (stakeholder chose over document-and-accept) +- Empty dir: exit code 1 (error, not warning) +- YAML: fix all commands, not just export diff --git a/docs/post-mortem/PM_20260507_cross-adapter-flags.md b/docs/post-mortem/PM_20260507_cross-adapter-flags.md new file mode 100644 index 0000000..14c3934 --- /dev/null +++ b/docs/post-mortem/PM_20260507_cross-adapter-flags.md @@ -0,0 +1,24 @@ +# PM_20260507_cross-adapter-flags: Adapter-specific CLI flags visible for all formats + +## Failed At + +acceptance (delivery-flow) — adversarial dry-run: "flowr export --format mermaid --flat produces normal output with no indication that --flat was ignored. All adapter flags (--flat, --no-attrs, --no-conditions) appear in --help regardless of --format value." + +## Root Cause + +The export subcommand registers all adapter flags via a loop over EXPORTERS in `_add_subcommands()`, making every adapter's flags visible to every format. When a flag is parsed but the selected adapter doesn't consume it, argparse accepts it silently and the adapter ignores the unknown option key. + +## Missed Gate + +Design review (review-gate-flow) verified per-adapter flags were wired but did not test cross-adapter flag usage. The BDD scenarios `1d5ba172` and `0ce7099f` verify that correct flags appear in help for the matching format, but no scenario tests that incorrect flags are absent from help or rejected at runtime. + +## Fix + +Either: +1. Register adapter flags conditionally based on `--format` value (deferred parsing — two-pass argparse), or +2. Accept the current design and add a runtime warning when adapter options contain keys the adapter doesn't recognize, or +3. Document the behavior explicitly: "all flags are accepted; only those relevant to the selected format are used." + +## Restart Check + +After modifying adapter flag registration, verify: `flowr export --format mermaid --help` must NOT contain `--flat` or `--no-attrs`, OR the runtime must warn on unused flags. diff --git a/docs/post-mortem/PM_20260507_empty-directory-silent.md b/docs/post-mortem/PM_20260507_empty-directory-silent.md new file mode 100644 index 0000000..1588e54 --- /dev/null +++ b/docs/post-mortem/PM_20260507_empty-directory-silent.md @@ -0,0 +1,21 @@ +# PM_20260507_empty-directory-silent: Exporting empty directory returns [] with exit code 0 + +## Failed At + +acceptance (delivery-flow) — adversarial dry-run: "flowr export --format json /tmp/empty_flows outputs [] with exit code 0. No warning that the directory contained no flow files." + +## Root Cause + +`_load_flows_from_directory()` filters for `.yaml`/`.yml` files, loads each, and returns a list. When the directory contains no matching files, the list is empty. No code path checks whether the result is empty before proceeding. + +## Missed Gate + +The BDD scenario `e4152bc9` (directory input triggers collection export) tests a directory with YAML files present. No scenario tests an empty directory or a directory with only non-YAML files. The test suite verifies the happy path but not the zero-results edge case. + +## Fix + +In `_cmd_export`, after loading flows from a directory, check if the result is empty. If so, print a warning to stderr: "no flow files found in " and either exit 0 (informational) or exit 1 (error). The stakeholder should decide which severity is appropriate. + +## Restart Check + +After fixing, verify: `flowr export --format json /tmp/empty_dir` must produce a message on stderr indicating no flows were found. The exit code must be documented in the feature file or ADR. diff --git a/docs/post-mortem/PM_20260507_yaml-traceback-leak.md b/docs/post-mortem/PM_20260507_yaml-traceback-leak.md new file mode 100644 index 0000000..e374597 --- /dev/null +++ b/docs/post-mortem/PM_20260507_yaml-traceback-leak.md @@ -0,0 +1,21 @@ +# PM_20260507_yaml-traceback-leak: Malformed YAML leaks raw Python traceback to user + +## Failed At + +acceptance (delivery-flow) — adversarial dry-run: "flowr export --format json /tmp/bad.yaml" and "flowr validate /tmp/bad.yaml" both crash with a full Python traceback (yaml.scanner.ScannerError) instead of a user-friendly error message. + +## Root Cause + +The CLI catches `FlowParseError` (raised by the loader when a valid YAML file lacks required fields) but not `yaml.scanner.ScannerError` or `yaml.parser.ParserError` (raised by PyYAML when the file is not valid YAML at all). These are different exception types at different layers: PyYAML raises scanner/parser errors during parsing, while `FlowParseError` is raised during semantic validation after successful parsing. + +## Missed Gate + +This is a pre-existing defect that predates the export feature. No existing BDD scenario tests malformed YAML input across any command. The test suite only covers: (1) valid flows, (2) semantically invalid flows (missing fields, bad transitions), and (3) non-existent paths. The "valid YAML syntax but invalid flow structure" case is covered; the "invalid YAML syntax" case is not. + +## Fix + +In `main()`, add a catch for `yaml.YAMLError` (parent of both `ScannerError` and `ParserError`) alongside the existing `FlowParseError` catch. Print a user-friendly message: "error: invalid YAML in : " with exit code 1. This fix applies to all commands that load YAML, not just export. + +## Restart Check + +After fixing, verify: `echo "not: valid: yaml: [{" > /tmp/bad.yaml && flowr export --format json /tmp/bad.yaml` must produce a single-line error on stderr with no traceback, exit code 1. diff --git a/docs/spec/event_storming.md b/docs/spec/event_storming.md index 1918961..cf9e555 100644 --- a/docs/spec/event_storming.md +++ b/docs/spec/event_storming.md @@ -196,3 +196,69 @@ Each concrete adapter (JsonExporter, MermaidExporter) is an aggregate root respo | G3 | Directory export ordering is undefined | Flows loaded from directory glob — sorted alphabetically by filename for deterministic output | | G4 | `flowr mermaid` removal is a breaking change | Accepted per interview QA4; `flowr export --format mermaid` is the replacement path | | G5 | No streaming or incremental output for large directories | Out of scope for this feature; all flows loaded into memory before export | + +--- + +## Robustness Events (2026-05-07) + +Addendum from post-mortems PM_20260507_cross-adapter-flags, PM_20260507_empty-directory-silent, and PM_20260507_yaml-traceback-leak. Three edge-case failure events surfaced during production use. No new bounded contexts or aggregates — these extend existing BC1 (Export Coordination) and the CLI entry point. + +### New Domain Events + +| # | Event | Description | Produced by | +|---|-------|-------------|-------------| +| E9 | **UnusedAdapterFlagsWarningIssued** | One or more adapter-specific flags were provided but are irrelevant to the resolved export format; warning emitted on stderr | `ValidateAdapterFlags` command | +| E10 | **EmptyDirectoryRejected** | Directory export target contains no flow definition files; export aborted with error message and exit code 1 | `ClassifyInput` command (extended) | +| E11 | **MalformedYamlRejected** | A YAML file failed to parse due to structural errors; user-friendly error emitted on stderr, no traceback leaked | `LoadFlow` command (extended failure mode) | + +### New Commands + +| # | Command | Triggers event | Failure mode | +|---|---------|---------------|--------------| +| C9 | **ValidateAdapterFlags** | UnusedAdapterFlagsWarningIssued | — (warning only, does not block export) | + +### Extended Commands + +| Command | Change | Rationale | +|---------|--------|-----------| +| C8 **ExportDirectory** | Failure mode updated: "Empty directory → empty collection" becomes "Empty directory → error (exit 1)" | PM_20260507_empty-directory-silent: silent `[]` masked user mistakes | +| C5 **LoadFlow** | Failure mode extended: `yaml.YAMLError` caught at CLI layer in addition to existing `FlowParseError` | PM_20260507_yaml-traceback-leak: raw traceback leaked to end users | + +### Event–Command Pair + +| Command | → Event | Aggregate | +|---------|---------|-----------| +| `ValidateAdapterFlags(adapter_flags, resolved_format)` | `UnusedAdapterFlagsWarningIssued` | ExportSession | + +### Updated Timeline + +```mermaid +flowchart LR + ExportRequested --> FormatResolved --> AdapterArgumentsParsed + AdapterArgumentsParsed --> InputClassified + AdapterArgumentsParsed -.-> UnusedAdapterFlagsWarningIssued + InputClassified --> FlowsLoaded + InputClassified --> FlowLoaded + FlowsLoaded --> DirectoryExported + FlowsLoaded -.-> EmptyDirectoryRejected + FlowLoaded --> FlowExported + FlowLoaded -.-> MalformedYamlRejected +``` + +Dotted lines indicate failure/warning paths (non-happy-path). + +### Placement in Existing Contexts + +| Event | Context | Notes | +|-------|---------|-------| +| UnusedAdapterFlagsWarningIssued | C1: Export Coordination | Fires between AdapterArgumentsParsed and InputClassified | +| EmptyDirectoryRejected | C1: Export Coordination | Replaces the silent-empty behavior of C8 ExportDirectory | +| MalformedYamlRejected | C3: Flow Resolution (caught at CLI) | `yaml.YAMLError` catch lives in `main()` — the CLI boundary — but the event originates from the Flow Resolution context | + +### No New Aggregates + +All three events operate within existing aggregate boundaries: + +- **ExportSession** (A1): gains the `ValidateAdapterFlags` step and the empty-directory guard. +- **ExportRegistry** (A2): unchanged. +- **FlowExporter** (A3): unchanged — adapters are not responsible for input validation. diff --git a/tests/features/export-robustness/__init__.py b/tests/features/export-robustness/__init__.py new file mode 100644 index 0000000..9f22d0b --- /dev/null +++ b/tests/features/export-robustness/__init__.py @@ -0,0 +1 @@ +"""Tests for export robustness: unused flags, empty directory, malformed YAML.""" diff --git a/tests/features/export-robustness/export_robustness_test.py b/tests/features/export-robustness/export_robustness_test.py new file mode 100644 index 0000000..c4705d1 --- /dev/null +++ b/tests/features/export-robustness/export_robustness_test.py @@ -0,0 +1,63 @@ +"""Tests for export robustness: unused flags, empty directory, malformed YAML.""" + +import pytest + + +@pytest.mark.skip(reason="not yet implemented") +def test_export_robustness_a1b2c3d4(tmp_path): + """JSON format with --no-conditions flag. + + Given a flow definition file exists at `examples/simple.yaml` + When the user runs `flowr export --format json --no-conditions examples/simple.yaml` + Then the command prints a warning to stderr containing "no-conditions" and exits with code 0 + """ + + +@pytest.mark.skip(reason="not yet implemented") +def test_export_robustness_e5f6a7b8(tmp_path): + """Mermaid format with --flat flag. + + Given a flow definition file exists at `examples/simple.yaml` + When the user runs `flowr export --format mermaid --flat examples/simple.yaml` + Then the command prints a warning to stderr containing "flat" and exits with code 0 + """ + + +@pytest.mark.skip(reason="not yet implemented") +def test_export_robustness_c9d0e1f2(tmp_path): + """Export from empty directory. + + Given a directory exists at `/tmp/empty_flows` with no YAML files + When the user runs `flowr export --format json /tmp/empty_flows` + Then the command prints an error to stderr stating no flow files were found and exits with code 1 + """ + + +@pytest.mark.skip(reason="not yet implemented") +def test_export_robustness_a3b4c5d6(tmp_path): + """Export from directory with only non-YAML files. + + Given a directory exists at `/tmp/mixed` containing only .txt and .json files + When the user runs `flowr export --format json /tmp/mixed` + Then the command prints an error to stderr stating no flow files were found and exits with code 1 + """ + + +@pytest.mark.skip(reason="not yet implemented") +def test_export_robustness_e7f8a9b0(tmp_path): + """Malformed YAML with export command. + + Given a file at `/tmp/bad.yaml` contains invalid YAML syntax + When the user runs `flowr export --format json /tmp/bad.yaml` + Then the command prints a single-line error to stderr with no traceback and exits with code 1 + """ + + +@pytest.mark.skip(reason="not yet implemented") +def test_export_robustness_c1d2e3f4(tmp_path): + """Malformed YAML with validate command. + + Given a file at `/tmp/bad.yaml` contains invalid YAML syntax + When the user runs `flowr validate /tmp/bad.yaml` + Then the command prints a single-line error to stderr with no traceback and exits with code 1 + """ From 606b50409e7369975ea16b95b3e278c39d142cc8 Mon Sep 17 00:00:00 2001 From: nullhack Date: Thu, 7 May 2026 02:35:38 -0400 Subject: [PATCH 2/4] fix(robustness): warn on unused adapter flags, reject empty dirs, catch malformed YAML - Warn on stderr when adapter flags irrelevant to selected format are used - Error exit code 1 when exporting from empty or non-YAML directory - Catch yaml.YAMLError across all commands with single-line error - Add accepted_options() to FlowExporter Protocol - Extract _run_command() helper to reduce main() complexity @a1b2c3d4 @e5f6a7b8 @c9d0e1f2 @a3b4c5d6 @e7f8a9b0 @c1d2e3f4 --- flowr/__main__.py | 43 +++-- flowr/domain/export.py | 4 + flowr/exporters/json_exporter.py | 4 + flowr/exporters/mermaid_exporter.py | 4 + .../export_robustness_test.py | 63 ------- .../__init__.py | 0 .../export_robustness_test.py | 161 ++++++++++++++++++ 7 files changed, 206 insertions(+), 73 deletions(-) delete mode 100644 tests/features/export-robustness/export_robustness_test.py rename tests/features/{export-robustness => export_robustness}/__init__.py (100%) create mode 100644 tests/features/export_robustness/export_robustness_test.py diff --git a/flowr/__main__.py b/flowr/__main__.py index 957b93c..71870ac 100644 --- a/flowr/__main__.py +++ b/flowr/__main__.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import Any +import yaml + from flowr.cli.output import format_json, format_text from flowr.cli.resolution import DefaultFlowNameResolver, FlowNameNotFoundError from flowr.cli.session_cmd import ( @@ -521,8 +523,19 @@ def _cmd_export(args: argparse.Namespace) -> int: _error(f"unknown format '{args.export_format}'. available: {available}") return 1 options = _extract_adapter_options(args) + accepted = adapter.accepted_options() + unused = [k for k in options if options[k] and k not in accepted] + if unused: + flag_names = ", ".join(f"--{k.replace('_', '-')}" for k in unused) + print( # noqa: T201 + f"warning: unused flags for format '{args.export_format}': {flag_names}", + file=sys.stderr, + ) if input_path.is_dir(): flows = _load_flows_from_directory(input_path) + if not flows: + _error(f"no flow files found in directory: {args.input_path}") + return 1 output = adapter.export_directory(flows, options) else: flow = load_flow_from_file(input_path) @@ -1016,6 +1029,23 @@ def _dispatch_session_command( return True +def _run_command( + handler: object, args: argparse.Namespace, *, export: bool = False +) -> None: + """Run a command handler with unified error handling.""" + try: + if export: + sys.exit(_cmd_export(args)) + else: + sys.exit(handler(args)) + except yaml.YAMLError as exc: + _error(f"malformed YAML: {str(exc).splitlines()[0]}") + sys.exit(1) + except FlowParseError as exc: + _error(f"invalid flow definition: {exc}") + sys.exit(1) + + def main() -> None: """Run the application.""" args = build_parser().parse_args() @@ -1037,11 +1067,8 @@ def main() -> None: sys.exit(rc) # pragma: no cover if args.command == "export": - try: - sys.exit(_cmd_export(args)) - except FlowParseError as exc: - _error(f"invalid flow definition: {exc}") - sys.exit(1) + _run_command(None, args, export=True) + return # pragma: no cover if _dispatch_session_command(args, config, resolver): return @@ -1060,11 +1087,7 @@ def main() -> None: if handler is None: # pragma: no cover _error(f"Unknown command: {args.command}") sys.exit(2) - try: - sys.exit(handler(args)) - except FlowParseError as exc: - _error(f"invalid flow definition: {exc}") - sys.exit(1) + _run_command(handler, args) if __name__ == "__main__": diff --git a/flowr/domain/export.py b/flowr/domain/export.py index 3fff8e4..9ce5366 100644 --- a/flowr/domain/export.py +++ b/flowr/domain/export.py @@ -20,6 +20,10 @@ def supports_directory(self) -> bool: # pragma: no cover """Return True if the adapter supports directory-mode export.""" ... + def accepted_options(self) -> list[str]: # pragma: no cover + """Return the option keys this adapter consumes.""" + ... + def add_arguments(self, parser: object) -> None: # pragma: no cover """Register adapter-specific CLI flags on the argparse parser.""" ... diff --git a/flowr/exporters/json_exporter.py b/flowr/exporters/json_exporter.py index 33d6e90..716fcd3 100644 --- a/flowr/exporters/json_exporter.py +++ b/flowr/exporters/json_exporter.py @@ -22,6 +22,10 @@ def supports_directory(self) -> bool: """Return True — JSON adapter supports directory-mode export.""" return True + def accepted_options(self) -> list[str]: + """Return the option keys the JSON adapter consumes.""" + return ["flat", "no_attrs"] + def add_arguments(self, parser: object) -> None: """Register JSON-specific CLI flags.""" p: argparse.ArgumentParser = parser # type: ignore[assignment] diff --git a/flowr/exporters/mermaid_exporter.py b/flowr/exporters/mermaid_exporter.py index 62c6932..e0bdd46 100644 --- a/flowr/exporters/mermaid_exporter.py +++ b/flowr/exporters/mermaid_exporter.py @@ -21,6 +21,10 @@ def supports_directory(self) -> bool: """Return True — Mermaid adapter supports directory-mode export.""" return True + def accepted_options(self) -> list[str]: + """Return the option keys the Mermaid adapter consumes.""" + return ["no_conditions"] + def add_arguments(self, parser: object) -> None: """Register Mermaid-specific CLI flags.""" p: argparse.ArgumentParser = parser # type: ignore[assignment] diff --git a/tests/features/export-robustness/export_robustness_test.py b/tests/features/export-robustness/export_robustness_test.py deleted file mode 100644 index c4705d1..0000000 --- a/tests/features/export-robustness/export_robustness_test.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Tests for export robustness: unused flags, empty directory, malformed YAML.""" - -import pytest - - -@pytest.mark.skip(reason="not yet implemented") -def test_export_robustness_a1b2c3d4(tmp_path): - """JSON format with --no-conditions flag. - - Given a flow definition file exists at `examples/simple.yaml` - When the user runs `flowr export --format json --no-conditions examples/simple.yaml` - Then the command prints a warning to stderr containing "no-conditions" and exits with code 0 - """ - - -@pytest.mark.skip(reason="not yet implemented") -def test_export_robustness_e5f6a7b8(tmp_path): - """Mermaid format with --flat flag. - - Given a flow definition file exists at `examples/simple.yaml` - When the user runs `flowr export --format mermaid --flat examples/simple.yaml` - Then the command prints a warning to stderr containing "flat" and exits with code 0 - """ - - -@pytest.mark.skip(reason="not yet implemented") -def test_export_robustness_c9d0e1f2(tmp_path): - """Export from empty directory. - - Given a directory exists at `/tmp/empty_flows` with no YAML files - When the user runs `flowr export --format json /tmp/empty_flows` - Then the command prints an error to stderr stating no flow files were found and exits with code 1 - """ - - -@pytest.mark.skip(reason="not yet implemented") -def test_export_robustness_a3b4c5d6(tmp_path): - """Export from directory with only non-YAML files. - - Given a directory exists at `/tmp/mixed` containing only .txt and .json files - When the user runs `flowr export --format json /tmp/mixed` - Then the command prints an error to stderr stating no flow files were found and exits with code 1 - """ - - -@pytest.mark.skip(reason="not yet implemented") -def test_export_robustness_e7f8a9b0(tmp_path): - """Malformed YAML with export command. - - Given a file at `/tmp/bad.yaml` contains invalid YAML syntax - When the user runs `flowr export --format json /tmp/bad.yaml` - Then the command prints a single-line error to stderr with no traceback and exits with code 1 - """ - - -@pytest.mark.skip(reason="not yet implemented") -def test_export_robustness_c1d2e3f4(tmp_path): - """Malformed YAML with validate command. - - Given a file at `/tmp/bad.yaml` contains invalid YAML syntax - When the user runs `flowr validate /tmp/bad.yaml` - Then the command prints a single-line error to stderr with no traceback and exits with code 1 - """ diff --git a/tests/features/export-robustness/__init__.py b/tests/features/export_robustness/__init__.py similarity index 100% rename from tests/features/export-robustness/__init__.py rename to tests/features/export_robustness/__init__.py diff --git a/tests/features/export_robustness/export_robustness_test.py b/tests/features/export_robustness/export_robustness_test.py new file mode 100644 index 0000000..d117bf3 --- /dev/null +++ b/tests/features/export_robustness/export_robustness_test.py @@ -0,0 +1,161 @@ +"""Tests for export robustness: unused flags, empty directory, malformed YAML.""" + +import json +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from flowr.__main__ import main + +_SIMPLE_YAML = """\ +flow: test-flow +version: "1.0" +exits: [done] +states: + - id: start + next: + ready: done + - id: done +""" + +_MALFORMED_YAML = "flow: [\n invalid: {" + + +def _write_flow(path: Path, content: str = _SIMPLE_YAML) -> Path: + path.write_text(content) + return path + + +def test_export_robustness_a1b2c3d4(capsys, tmp_path): + """JSON format with --no-conditions flag. + + Given a flow definition file exists at `examples/simple.yaml` + When the user runs `flowr export --format json --no-conditions examples/simple.yaml` + Then the command prints a warning to stderr containing "no-conditions" and exits with code 0 + """ + flow_file = _write_flow(tmp_path / "simple.yaml") + with ( + patch.object( + sys, + "argv", + ["flowr", "export", "--format", "json", "--no-conditions", str(flow_file)], + ), + pytest.raises(SystemExit) as exc_info, + ): + main() + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "no-conditions" in captured.err + data = json.loads(captured.out) + assert data["flow"] == "test-flow" + + +def test_export_robustness_e5f6a7b8(capsys, tmp_path): + """Mermaid format with --flat flag. + + Given a flow definition file exists at `examples/simple.yaml` + When the user runs `flowr export --format mermaid --flat examples/simple.yaml` + Then the command prints a warning to stderr containing "flat" and exits with code 0 + """ + flow_file = _write_flow(tmp_path / "simple.yaml") + with ( + patch.object( + sys, + "argv", + ["flowr", "export", "--format", "mermaid", "--flat", str(flow_file)], + ), + pytest.raises(SystemExit) as exc_info, + ): + main() + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "flat" in captured.err + assert "stateDiagram-v2" in captured.out + + +def test_export_robustness_c9d0e1f2(capsys, tmp_path): + """Export from empty directory. + + Given a directory exists at `/tmp/empty_flows` with no YAML files + When the user runs `flowr export --format json /tmp/empty_flows` + Then the command prints an error to stderr stating no flow files were found and exits with code 1 + """ + empty_dir = tmp_path / "empty_flows" + empty_dir.mkdir() + with ( + patch.object( + sys, "argv", ["flowr", "export", "--format", "json", str(empty_dir)] + ), + pytest.raises(SystemExit) as exc_info, + ): + main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "no flow files" in captured.err + + +def test_export_robustness_a3b4c5d6(capsys, tmp_path): + """Export from directory with only non-YAML files. + + Given a directory exists at `/tmp/mixed` containing only .txt and .json files + When the user runs `flowr export --format json /tmp/mixed` + Then the command prints an error to stderr stating no flow files were found and exits with code 1 + """ + mixed_dir = tmp_path / "mixed" + mixed_dir.mkdir() + (mixed_dir / "readme.txt").write_text("not a flow") + (mixed_dir / "data.json").write_text("{}") + with ( + patch.object( + sys, "argv", ["flowr", "export", "--format", "json", str(mixed_dir)] + ), + pytest.raises(SystemExit) as exc_info, + ): + main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "no flow files" in captured.err + + +def test_export_robustness_e7f8a9b0(capsys, tmp_path): + """Malformed YAML with export command. + + Given a file at `/tmp/bad.yaml` contains invalid YAML syntax + When the user runs `flowr export --format json /tmp/bad.yaml` + Then the command prints a single-line error to stderr with no traceback and exits with code 1 + """ + bad_file = _write_flow(tmp_path / "bad.yaml", _MALFORMED_YAML) + with ( + patch.object( + sys, "argv", ["flowr", "export", "--format", "json", str(bad_file)] + ), + pytest.raises(SystemExit) as exc_info, + ): + main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert captured.err.strip() + assert "Traceback" not in captured.err + assert "\n" not in captured.err.strip() + + +def test_export_robustness_c1d2e3f4(capsys, tmp_path): + """Malformed YAML with validate command. + + Given a file at `/tmp/bad.yaml` contains invalid YAML syntax + When the user runs `flowr validate /tmp/bad.yaml` + Then the command prints a single-line error to stderr with no traceback and exits with code 1 + """ + bad_file = _write_flow(tmp_path / "bad.yaml", _MALFORMED_YAML) + with ( + patch.object(sys, "argv", ["flowr", "validate", str(bad_file)]), + pytest.raises(SystemExit) as exc_info, + ): + main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert captured.err.strip() + assert "Traceback" not in captured.err + assert "\n" not in captured.err.strip() From 17a1f9b8513286254c99398d05b6e628a57608a5 Mon Sep 17 00:00:00 2001 From: nullhack Date: Thu, 7 May 2026 02:38:52 -0400 Subject: [PATCH 3/4] fix(robustness): add Callable type annotation for pyright compatibility --- flowr/__main__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flowr/__main__.py b/flowr/__main__.py index 71870ac..0ef0c8a 100644 --- a/flowr/__main__.py +++ b/flowr/__main__.py @@ -3,6 +3,7 @@ import argparse import importlib.metadata import sys +from collections.abc import Callable from pathlib import Path from typing import Any @@ -1030,7 +1031,10 @@ def _dispatch_session_command( def _run_command( - handler: object, args: argparse.Namespace, *, export: bool = False + handler: Callable[[argparse.Namespace], int] | None, + args: argparse.Namespace, + *, + export: bool = False, ) -> None: """Run a command handler with unified error handling.""" try: From b602c225d192d9c4a79ee82bc23f9d34014f36d7 Mon Sep 17 00:00:00 2001 From: nullhack Date: Thu, 7 May 2026 02:41:00 -0400 Subject: [PATCH 4/4] fix(robustness): split _run_command into _run_command and _run_export for pyright --- flowr/__main__.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/flowr/__main__.py b/flowr/__main__.py index 0ef0c8a..4505c3b 100644 --- a/flowr/__main__.py +++ b/flowr/__main__.py @@ -1031,17 +1031,24 @@ def _dispatch_session_command( def _run_command( - handler: Callable[[argparse.Namespace], int] | None, + handler: Callable[[argparse.Namespace], int], args: argparse.Namespace, - *, - export: bool = False, ) -> None: """Run a command handler with unified error handling.""" try: - if export: - sys.exit(_cmd_export(args)) - else: - sys.exit(handler(args)) + sys.exit(handler(args)) + except yaml.YAMLError as exc: + _error(f"malformed YAML: {str(exc).splitlines()[0]}") + sys.exit(1) + except FlowParseError as exc: + _error(f"invalid flow definition: {exc}") + sys.exit(1) + + +def _run_export(args: argparse.Namespace) -> None: + """Run the export command with unified error handling.""" + try: + sys.exit(_cmd_export(args)) except yaml.YAMLError as exc: _error(f"malformed YAML: {str(exc).splitlines()[0]}") sys.exit(1) @@ -1071,7 +1078,7 @@ def main() -> None: sys.exit(rc) # pragma: no cover if args.command == "export": - _run_command(None, args, export=True) + _run_export(args) return # pragma: no cover if _dispatch_session_command(args, config, resolver):