Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/features/export-robustness.feature
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions docs/interview-notes/IN_20260507_export-robustness.md
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions docs/post-mortem/PM_20260507_cross-adapter-flags.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions docs/post-mortem/PM_20260507_empty-directory-silent.md
Original file line number Diff line number Diff line change
@@ -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 <path>" 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.
21 changes: 21 additions & 0 deletions docs/post-mortem/PM_20260507_yaml-traceback-leak.md
Original file line number Diff line number Diff line change
@@ -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 <path>: <yaml error message>" 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.
66 changes: 66 additions & 0 deletions docs/spec/event_storming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
54 changes: 44 additions & 10 deletions flowr/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
import argparse
import importlib.metadata
import sys
from collections.abc import Callable
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 (
Expand Down Expand Up @@ -521,8 +524,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)
Expand Down Expand Up @@ -1016,6 +1030,33 @@ def _dispatch_session_command(
return True


def _run_command(
handler: Callable[[argparse.Namespace], int],
args: argparse.Namespace,
) -> None:
"""Run a command handler with unified error handling."""
try:
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)
except FlowParseError as exc:
_error(f"invalid flow definition: {exc}")
sys.exit(1)


def main() -> None:
"""Run the application."""
args = build_parser().parse_args()
Expand All @@ -1037,11 +1078,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_export(args)
return # pragma: no cover

if _dispatch_session_command(args, config, resolver):
return
Expand All @@ -1060,11 +1098,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__":
Expand Down
4 changes: 4 additions & 0 deletions flowr/domain/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
...
Expand Down
4 changes: 4 additions & 0 deletions flowr/exporters/json_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions flowr/exporters/mermaid_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions tests/features/export_robustness/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for export robustness: unused flags, empty directory, malformed YAML."""
Loading
Loading