From cac18681d1d4524d172c51f4fb6108257e836104 Mon Sep 17 00:00:00 2001 From: nihalnihalani <24360630+nihalnihalani@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:35:24 +0530 Subject: [PATCH 1/4] =?UTF-8?q?feat(sdk):=20rocketride.pipediff=20?= =?UTF-8?q?=E2=80=94=20semantic=20diff=20engine=20for=20.pipe=20files=20(#?= =?UTF-8?q?1606)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New pipediff package: a structural diff that surfaces what actually changed in a pipeline and hides the canvas coordinate churn that dominates a raw JSON diff. - engine.py: strict-JSON load + shape validation; match components by id; report added/removed nodes, provider changes, and a deep config diff rendered as dotted field paths (config.parameters.model: gpt-4 -> gpt-4o) over nested dicts and lists; reconstruct edges from each component's input[] (data lanes) AND control[] (agent orchestration wiring — present in most bundled examples) as a (from, lane, to) set diff. - The ui block (position/measured), top-level viewport, and untouched- node layout are ignored by default and collapse to a single 'layout changed' flag; include_layout surfaces them. A pure canvas move is never mistaken for a semantic change. - gitref.py: resolve a pipe from a git ref via 'git show' (arg-list, never a shell string; None when the path is absent in the ref = all-added; PipeDiffError on git/parse failure). - model.py: frozen FieldChange/EdgeChange + NodeChange/PipeDiff with a has_semantic_changes property. 49 unit tests (engine + gitref), including a real tmp-repo git integration test. Co-Authored-By: Claude Fable 5 --- .../src/rocketride/pipediff/__init__.py | 67 ++ .../src/rocketride/pipediff/engine.py | 449 +++++++++++++ .../src/rocketride/pipediff/gitref.py | 159 +++++ .../src/rocketride/pipediff/model.py | 182 ++++++ .../src/rocketride/pipediff/reporters.py | 613 ++++++++++++++++++ .../tests/test_pipediff_engine.py | 426 ++++++++++++ .../tests/test_pipediff_gitref.py | 194 ++++++ .../tests/test_pipediff_reporters.py | 367 +++++++++++ 8 files changed, 2457 insertions(+) create mode 100644 packages/client-python/src/rocketride/pipediff/__init__.py create mode 100644 packages/client-python/src/rocketride/pipediff/engine.py create mode 100644 packages/client-python/src/rocketride/pipediff/gitref.py create mode 100644 packages/client-python/src/rocketride/pipediff/model.py create mode 100644 packages/client-python/src/rocketride/pipediff/reporters.py create mode 100644 packages/client-python/tests/test_pipediff_engine.py create mode 100644 packages/client-python/tests/test_pipediff_gitref.py create mode 100644 packages/client-python/tests/test_pipediff_reporters.py diff --git a/packages/client-python/src/rocketride/pipediff/__init__.py b/packages/client-python/src/rocketride/pipediff/__init__.py new file mode 100644 index 000000000..567b1c594 --- /dev/null +++ b/packages/client-python/src/rocketride/pipediff/__init__.py @@ -0,0 +1,67 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Semantic diff for RocketRide ``.pipe`` pipeline files. + +``rocketride diff`` surfaces what actually changed between two pipeline +definitions — added/removed/reconfigured nodes and rewired edges — instead of the +raw JSON churn dominated by canvas coordinates. This package is the pure, +network-free implementation behind the CLI subcommand and the PR-comment GitHub +Action. + +Public API: + Data model: + NodeChange, FieldChange, EdgeChange, PipeDiff + Engine: + load_pipe, diff_pipes, deep_diff_config, PipeDiffError + Git resolution: + resolve_git_ref + Reporters (rendering; provided alongside this module): + render_human, render_json, render_markdown +""" + +from .engine import PipeDiffError, deep_diff_config, diff_pipes, load_pipe +from .gitref import resolve_git_ref +from .model import EdgeChange, FieldChange, NodeChange, PipeDiff + +__all__ = [ + 'NodeChange', + 'FieldChange', + 'EdgeChange', + 'PipeDiff', + 'PipeDiffError', + 'load_pipe', + 'diff_pipes', + 'deep_diff_config', + 'resolve_git_ref', +] + +# Reporters live in the sibling ``reporters`` module (implemented in parallel). +# Import them opportunistically so the public surface is complete once they land, +# without making this package unimportable while they are still in progress. +try: + from .reporters import render_human, render_json, render_markdown # noqa: F401 +except ImportError: + pass +else: + __all__ += ['render_human', 'render_json', 'render_markdown'] diff --git a/packages/client-python/src/rocketride/pipediff/engine.py b/packages/client-python/src/rocketride/pipediff/engine.py new file mode 100644 index 000000000..e05b55bd1 --- /dev/null +++ b/packages/client-python/src/rocketride/pipediff/engine.py @@ -0,0 +1,449 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +The semantic diff engine for RocketRide ``.pipe`` pipeline files. + +This module is the pure, network-free core of ``rocketride diff``. It loads and +validates pipeline JSON, then computes the semantic difference between two +pipelines: added/removed/re-provisioned/reconfigured nodes, added/removed edges, +version changes, and a coarse layout-changed flag. Nothing here touches the +RocketRide engine, a server, or the network — it is deliberately restricted to +local file and in-memory JSON work. + +Semantic model (see :mod:`rocketride.pipediff.model` for the shapes): + - **Nodes** are matched by their ``id``. Provider (node-type) changes and + deep config changes are reported independently. + - **Config** is deep-diffed into readable dotted paths (``config.a.b[0]``), + handling nested dictionaries and lists. + - **Edges** are the set of directed wires reconstructed from every + component's ``input[]`` (data lanes) and ``control[]`` (agent orchestration + lanes); added and removed wires are reported as a set difference. + - **Layout** — each component's ``ui`` block and the top-level ``viewport`` + are ignored by default and summarised by ``layout_changed``. When + ``include_layout`` is set, ``ui`` differences are additionally enumerated + as ``ui.*`` field changes on the affected node. + +Functions: + load_pipe: Read and validate a pipe from a path or an already-parsed dict. + deep_diff_config: Deep-diff two config dicts into a list of ``FieldChange``. + diff_pipes: Compute the full ``PipeDiff`` between two pipeline dicts. + +Exceptions: + PipeDiffError: Raised for unreadable, unparseable, or structurally invalid + pipelines, with human-readable context about the offending source. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from .model import EdgeChange, FieldChange, NodeChange, PipeDiff + + +class PipeDiffError(Exception): + """ + Raised when a ``.pipe`` file cannot be read, parsed, or validated. + + The message always carries enough context (the source path or ```` plus + the specific problem) for the CLI to print an actionable usage error and exit + with code ``2``. It is used both by :func:`load_pipe` (file/JSON/shape errors) + and by :func:`rocketride.pipediff.gitref.resolve_git_ref` (git/parse errors). + """ + + +def load_pipe(path_or_obj: str | os.PathLike[str] | dict) -> dict: + """ + Load and validate a pipeline definition from a path or a parsed object. + + Accepts either a filesystem path (``str`` or ``os.PathLike``) pointing at a + ``.pipe`` JSON file, or an already-parsed ``dict`` (for example, the object + returned by resolving a git ref). In both cases the result is validated to be + a JSON object containing a ``components`` list of id-bearing objects. + + Args: + path_or_obj: A path to a ``.pipe`` file, or a parsed pipeline ``dict``. + + Returns: + The validated pipeline as a ``dict`` (the same object when a dict is + passed in; a freshly parsed object when a path is passed in). + + Raises: + PipeDiffError: If the file cannot be read, the JSON cannot be parsed, the + top level is not an object, ``components`` is missing or not a list, + or any component is not an object with a non-empty string ``id``. + """ + if isinstance(path_or_obj, dict): + return _validate_pipe(path_or_obj, '') + + if isinstance(path_or_obj, (str, os.PathLike)): + source = os.fspath(path_or_obj) + try: + with open(source, encoding='utf-8') as handle: + text = handle.read() + except FileNotFoundError as exc: + raise PipeDiffError(f'Pipe file not found: {source}') from exc + except OSError as exc: + raise PipeDiffError(f'Could not read pipe file {source}: {exc}') from exc + try: + obj = json.loads(text) + except json.JSONDecodeError as exc: + raise PipeDiffError(f'Invalid JSON in {source}: {exc}') from exc + return _validate_pipe(obj, source) + + raise PipeDiffError(f'load_pipe expects a path or dict, got {type(path_or_obj).__name__}') + + +def _validate_pipe(obj: Any, source: str) -> dict: + """ + Validate that ``obj`` is a well-formed pipeline object and return it. + + Args: + obj: The candidate pipeline (any JSON-decoded value). + source: A label for error messages (a path, or ``""``). + + Returns: + The validated pipeline ``dict`` (``obj`` unchanged). + + Raises: + PipeDiffError: If ``obj`` is not an object, lacks a ``components`` list, + or contains a component that is not an id-bearing object. + """ + if not isinstance(obj, dict): + raise PipeDiffError(f'{source}: pipe must be a JSON object, got {type(obj).__name__}') + components = obj.get('components') + if not isinstance(components, list): + raise PipeDiffError(f"{source}: pipe is missing a 'components' list") + for index, component in enumerate(components): + if not isinstance(component, dict): + raise PipeDiffError(f'{source}: component at index {index} is not an object') + component_id = component.get('id') + if not isinstance(component_id, str) or not component_id: + raise PipeDiffError(f"{source}: component at index {index} is missing a string 'id'") + return obj + + +def deep_diff_config(old: dict | None, new: dict | None) -> list[FieldChange]: + """ + Deep-diff two component config dictionaries into dotted-path field changes. + + Recursively compares nested dictionaries and lists, producing one + :class:`FieldChange` per differing leaf. Dict keys extend the path with + ``.key``; list elements extend it with ``[index]``. List differences are + reported index-by-index, with trailing extra elements reported as added or + removed. Every path is prefixed with ``config`` so it reads as a fully + qualified path (for example ``config.instructions[0]`` or + ``config.default.strlen``). + + Args: + old: The previous config dict (``None`` is treated as an empty dict). + new: The new config dict (``None`` is treated as an empty dict). + + Returns: + The list of field changes, ordered deterministically by key/index. Empty + when the two configs are equal. + """ + return _diff_value(old if old is not None else {}, new if new is not None else {}, 'config') + + +def diff_pipes(old: dict, new: dict, *, include_layout: bool = False) -> PipeDiff: + """ + Compute the semantic difference between two pipeline dictionaries. + + Nodes are matched by ``id``. For each matched node the provider and config + are compared independently; unmatched nodes become added/removed changes. + Edges are compared as a set of directed ``(from, lane, to)`` triples drawn + from every component's ``input[]`` and ``control[]`` wiring. The top-level + ``version`` change is always reported. Canvas layout (each ``ui`` block and + the ``viewport``) is summarised by ``layout_changed`` and, when + ``include_layout`` is set, enumerated as ``ui.*`` field changes per node. + + Args: + old: The previous pipeline (already loaded/validated via ``load_pipe``). + new: The new pipeline (already loaded/validated via ``load_pipe``). + include_layout: When ``True``, fold each node's ``ui`` differences into + its field changes (paths prefixed ``ui``) so canvas churn is opted + back into the reported changes. The ``layout_changed`` flag is + computed either way. + + Returns: + A :class:`PipeDiff` describing every node, edge, version, and layout + difference between ``old`` and ``new``. + """ + old_components = _components_by_id(old) + new_components = _components_by_id(new) + old_ids = set(old_components) + new_ids = set(new_components) + + node_changes: list[NodeChange] = [] + + for component_id in sorted(new_ids - old_ids): + node_changes.append( + NodeChange( + id=component_id, + kind='added', + provider_new=new_components[component_id].get('provider'), + ) + ) + for component_id in sorted(old_ids - new_ids): + node_changes.append( + NodeChange( + id=component_id, + kind='removed', + provider_old=old_components[component_id].get('provider'), + ) + ) + for component_id in sorted(old_ids & new_ids): + old_component = old_components[component_id] + new_component = new_components[component_id] + + provider_old = old_component.get('provider') + provider_new = new_component.get('provider') + if provider_old != provider_new: + node_changes.append( + NodeChange( + id=component_id, + kind='provider', + provider_old=provider_old, + provider_new=provider_new, + ) + ) + + field_changes = deep_diff_config(old_component.get('config'), new_component.get('config')) + if include_layout: + field_changes = field_changes + _diff_value( + old_component.get('ui') or {}, new_component.get('ui') or {}, 'ui' + ) + if field_changes: + node_changes.append(NodeChange(id=component_id, kind='config', field_changes=field_changes)) + + old_edges = _extract_edges(old) + new_edges = _extract_edges(new) + edge_changes: list[EdgeChange] = [] + for edge in sorted(new_edges - old_edges, key=_edge_sort_key): + edge_changes.append(EdgeChange(from_id=edge[0], lane=edge[1], to_id=edge[2], kind='added')) + for edge in sorted(old_edges - new_edges, key=_edge_sort_key): + edge_changes.append(EdgeChange(from_id=edge[0], lane=edge[1], to_id=edge[2], kind='removed')) + + version_change: tuple[Any, Any] | None = None + old_version = old.get('version') + new_version = new.get('version') + if old_version != new_version: + version_change = (old_version, new_version) + + layout_changed = _layout_changed(old, new, old_components, new_components, old_ids & new_ids) + + return PipeDiff( + node_changes=node_changes, + edge_changes=edge_changes, + version_change=version_change, + layout_changed=layout_changed, + ) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _components_by_id(pipe: dict) -> dict[str, dict]: + """ + Index a pipeline's components by their ``id``. + + Non-object components and components without an ``id`` are skipped defensively + (``load_pipe`` normally rejects those upstream). If duplicate ids appear, the + last one wins. + + Args: + pipe: A pipeline dict. + + Returns: + A mapping of component id to the component object. + """ + indexed: dict[str, dict] = {} + for component in pipe.get('components', []) or []: + if isinstance(component, dict) and isinstance(component.get('id'), str): + indexed[component['id']] = component + return indexed + + +def _extract_edges(pipe: dict) -> set[tuple[Any, Any, Any]]: + """ + Reconstruct the set of directed wires in a pipeline. + + Each component's ``input[]`` entries become data edges + ``(input.from, input.lane, component.id)`` and its ``control[]`` entries + become orchestration edges ``(control.from, control.classType, component.id)``. + Control edges are included because agent workflows express their + agent-to-llm/tool/memory wiring exclusively through ``control[]``; ignoring + them would make the diff blind to agent reconfiguration. + + Args: + pipe: A pipeline dict. + + Returns: + A set of ``(from, lane, to)`` triples. + """ + edges: set[tuple[Any, Any, Any]] = set() + for component in pipe.get('components', []) or []: + if not isinstance(component, dict): + continue + to_id = component.get('id') + for wire in component.get('input') or []: + if isinstance(wire, dict): + edges.add((wire.get('from'), wire.get('lane'), to_id)) + for wire in component.get('control') or []: + if isinstance(wire, dict): + edges.add((wire.get('from'), wire.get('classType'), to_id)) + return edges + + +def _edge_sort_key(edge: tuple[Any, Any, Any]) -> tuple[str, str, str]: + """ + Provide a total, deterministic ordering for edge triples. + + Coerces each element to ``str`` so triples containing ``None`` (malformed + wires missing a ``from``/``lane``) still sort without raising. + + Args: + edge: A ``(from, lane, to)`` triple. + + Returns: + A ``(str, str, str)`` sort key. + """ + return (str(edge[0]), str(edge[1]), str(edge[2])) + + +def _layout_changed( + old: dict, + new: dict, + old_components: dict[str, dict], + new_components: dict[str, dict], + common_ids: set[str], +) -> bool: + """ + Determine whether any pure-layout data differs between two pipelines. + + Layout is the top-level ``viewport`` plus each retained component's ``ui`` + block. Added/removed nodes are excluded: their appearance/disappearance is + already a semantic change, so counting their ``ui`` here would be redundant + noise. This flag is a coarse hint and never gates the exit code by itself. + + Args: + old: The previous pipeline dict. + new: The new pipeline dict. + old_components: The old components indexed by id. + new_components: The new components indexed by id. + common_ids: Ids present in both pipelines. + + Returns: + ``True`` if the viewport or any retained node's ui block differs. + """ + if old.get('viewport') != new.get('viewport'): + return True + for component_id in common_ids: + if old_components[component_id].get('ui') != new_components[component_id].get('ui'): + return True + return False + + +def _diff_value(old: Any, new: Any, path: str) -> list[FieldChange]: + """ + Recursively diff two arbitrary JSON values at ``path``. + + Dispatches on type: two dicts are diffed key-wise, two lists index-wise, and + anything else is compared for equality. This is the shared engine behind both + the config diff and (when layout is opted in) the ui diff. + + Args: + old: The previous value. + new: The new value. + path: The dotted path accumulated so far for this position. + + Returns: + The field changes at or beneath ``path``. Empty when equal. + """ + if isinstance(old, dict) and isinstance(new, dict): + return _diff_mapping(old, new, path) + if isinstance(old, list) and isinstance(new, list): + return _diff_sequence(old, new, path) + if old != new: + return [FieldChange(path=path, kind='changed', old=old, new=new)] + return [] + + +def _diff_mapping(old: dict, new: dict, prefix: str) -> list[FieldChange]: + """ + Diff two dictionaries, extending ``prefix`` with ``.key`` per entry. + + Keys are visited in sorted order so output is deterministic regardless of the + input insertion order. + + Args: + old: The previous dict. + new: The new dict. + prefix: The dotted path of the dict itself. + + Returns: + The field changes for every added, removed, or changed key. + """ + changes: list[FieldChange] = [] + for key in sorted(set(old) | set(new), key=str): + child_path = f'{prefix}.{key}' + in_old = key in old + in_new = key in new + if in_old and not in_new: + changes.append(FieldChange(path=child_path, kind='removed', old=old[key], new=None)) + elif in_new and not in_old: + changes.append(FieldChange(path=child_path, kind='added', old=None, new=new[key])) + else: + changes.extend(_diff_value(old[key], new[key], child_path)) + return changes + + +def _diff_sequence(old: list, new: list, prefix: str) -> list[FieldChange]: + """ + Diff two lists positionally, extending ``prefix`` with ``[index]``. + + Elements at shared indices are diffed recursively (so nested dict/list + elements produce fine-grained paths). Trailing elements present on only one + side are reported as added or removed at their index. + + Args: + old: The previous list. + new: The new list. + prefix: The dotted path of the list itself. + + Returns: + The field changes across all indices. + """ + changes: list[FieldChange] = [] + shared = min(len(old), len(new)) + for index in range(shared): + changes.extend(_diff_value(old[index], new[index], f'{prefix}[{index}]')) + for index in range(shared, len(new)): + changes.append(FieldChange(path=f'{prefix}[{index}]', kind='added', old=None, new=new[index])) + for index in range(shared, len(old)): + changes.append(FieldChange(path=f'{prefix}[{index}]', kind='removed', old=old[index], new=None)) + return changes diff --git a/packages/client-python/src/rocketride/pipediff/gitref.py b/packages/client-python/src/rocketride/pipediff/gitref.py new file mode 100644 index 000000000..bdd9711b0 --- /dev/null +++ b/packages/client-python/src/rocketride/pipediff/gitref.py @@ -0,0 +1,159 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Git ref resolution for ``rocketride diff --git ``. + +This module reads a pipeline file's contents at a given git ref so the working +tree can be diffed against history (for example ``rocketride diff --git HEAD +pipeline.pipe``). It shells out to ``git show`` via :mod:`subprocess` with an +argument list — never a shell string — so pipeline paths and refs cannot inject +shell commands. + +Like the rest of :mod:`rocketride.pipediff`, this never contacts the RocketRide +engine or the network; ``git`` is the only external process invoked. + +Functions: + resolve_git_ref: Return the parsed pipeline at ``ref``, or ``None`` if the + file does not exist in that ref (treated by the caller as an all-added + pipeline). +""" + +from __future__ import annotations + +import json +import os +import subprocess + +from .engine import PipeDiffError, load_pipe + +# Upper bound so a wedged git process can never hang the CLI indefinitely. +_GIT_TIMEOUT_SECONDS = 30 + +# Substrings git uses when a ref is valid but the path is absent from it. These +# distinguish "file not in this ref" (return None) from a genuine failure such as +# an unknown revision (raise PipeDiffError). +_PATH_ABSENT_MARKERS = ( + 'does not exist in', + 'exists on disk, but not in', +) + + +def resolve_git_ref(ref: str, file_path: str) -> dict | None: + """ + Load a pipeline file's contents at a specific git ref. + + Resolves ``file_path`` to its repository-relative location and runs + ``git show :`` to retrieve the file as it existed at + ``ref``, then parses and validates the result. Used to diff a working-tree + ``.pipe`` file against an arbitrary commit, branch, or tag. + + Args: + ref: A git revision (commit sha, branch, tag, ``HEAD``, ``HEAD~1``, ...). + file_path: Path to the working-tree ``.pipe`` file (absolute or relative + to the current working directory). + + Returns: + The parsed and validated pipeline ``dict`` at ``ref``, or ``None`` if the + file does not exist in ``ref`` (the caller treats this as "everything is + new"). + + Raises: + PipeDiffError: If the path is not inside a git repository, the ref is + unknown, ``git`` is unavailable or times out, or the retrieved + contents are not a valid pipeline. + """ + # realpath (not just abspath) so the file resolves to the same physical path + # that ``git rev-parse --show-toplevel`` reports, keeping relpath consistent + # even when the repo is reached through a symlinked directory. + abs_path = os.path.realpath(file_path) + file_dir = os.path.dirname(abs_path) or '.' + + repo_root = _repository_root(file_dir, file_path) + rel_path = os.path.relpath(abs_path, repo_root).replace(os.sep, '/') + spec = f'{ref}:{rel_path}' + + result = _run_git(['-C', repo_root, 'show', spec], f'reading {spec}') + if result.returncode != 0: + stderr = result.stderr.strip() + if any(marker in stderr for marker in _PATH_ABSENT_MARKERS): + return None + raise PipeDiffError(f'git show {spec} failed: {stderr}') + + try: + obj = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise PipeDiffError(f'Invalid JSON in {spec}: {exc}') from exc + return load_pipe(obj) + + +def _repository_root(file_dir: str, file_path: str) -> str: + """ + Resolve the git repository root that contains ``file_dir``. + + Args: + file_dir: A directory inside the repository (the file's parent). + file_path: The original path, used only for error context. + + Returns: + The absolute path to the repository top level. + + Raises: + PipeDiffError: If ``file_dir`` is not inside a git repository (or git + otherwise fails to resolve the top level). + """ + result = _run_git( + ['-C', file_dir, 'rev-parse', '--show-toplevel'], + f'resolving repository for {file_path}', + ) + if result.returncode != 0: + raise PipeDiffError(f'Not a git repository (or unable to resolve it) for {file_path}: {result.stderr.strip()}') + return result.stdout.strip() + + +def _run_git(args: list[str], context: str) -> subprocess.CompletedProcess: + """ + Run a ``git`` subcommand with a fixed argument list and no shell. + + Args: + args: Arguments passed to ``git`` (already split; never a shell string). + context: A short description of the operation for error messages. + + Returns: + The completed process (callers inspect ``returncode``/``stdout``/ + ``stderr``). + + Raises: + PipeDiffError: If ``git`` is not found on ``PATH`` or the call times out. + """ + try: + return subprocess.run( + ['git', *args], + capture_output=True, + text=True, + encoding='utf-8', + timeout=_GIT_TIMEOUT_SECONDS, + ) + except FileNotFoundError as exc: + raise PipeDiffError('git executable not found; --git requires git on PATH') from exc + except subprocess.TimeoutExpired as exc: + raise PipeDiffError(f'git timed out while {context}') from exc diff --git a/packages/client-python/src/rocketride/pipediff/model.py b/packages/client-python/src/rocketride/pipediff/model.py new file mode 100644 index 000000000..ffa2446a1 --- /dev/null +++ b/packages/client-python/src/rocketride/pipediff/model.py @@ -0,0 +1,182 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Semantic diff data model for RocketRide ``.pipe`` pipeline files. + +This module defines the immutable-ish value objects that describe *what changed* +between two pipeline definitions, independent of how the change is rendered. The +engine (:mod:`rocketride.pipediff.engine`) produces these objects and the +reporters (:mod:`rocketride.pipediff.reporters`) consume them, so the shapes here +are the contract between the two halves of the ``rocketride diff`` feature. + +The model deliberately mirrors the three semantic axes of a pipeline: + - **Nodes** — the components on the canvas (``NodeChange``). + - **Edges** — the directed wiring between components (``EdgeChange``). + - **Config** — the per-node settings, expressed as dotted field paths + (``FieldChange``, nested inside a ``NodeChange``). + +Canvas layout (each component's ``ui`` block and the top-level ``viewport``) is +*not* enumerated field-by-field by default; it is summarised by +``PipeDiff.layout_changed`` so that coordinate churn never drowns out real +changes. + +Classes: + FieldChange: A single dotted-path change within a component's config/ui. + NodeChange: An added, removed, re-provisioned, or reconfigured component. + EdgeChange: An added or removed directed wire between two components. + PipeDiff: The complete semantic difference between two pipelines. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal, Optional + +# Kind discriminators, kept as module-level aliases so the engine, reporters, +# and tests all refer to the same closed vocabularies. +FieldChangeKind = Literal['added', 'removed', 'changed'] +NodeChangeKind = Literal['added', 'removed', 'provider', 'config'] +EdgeChangeKind = Literal['added', 'removed'] + + +@dataclass(frozen=True) +class FieldChange: + """ + A single field-level change inside a component, keyed by a dotted path. + + Field changes are produced by the config deep-diff and (when layout is opted + in) the ui deep-diff. Paths are fully qualified and human-readable, e.g. + ``config.default.strlen``, ``config.instructions[0]``, or ``ui.position.x``. + List elements are addressed with ``[index]`` suffixes. + + Attributes: + path: Dotted path to the changed value, relative to the component + (prefixed with ``config`` or ``ui`` so it reads as an absolute path). + kind: ``"added"`` (present only in the new pipe), ``"removed"`` (present + only in the old pipe), or ``"changed"`` (present in both, differing). + old: The previous value, or ``None`` for an ``"added"`` change. + new: The new value, or ``None`` for a ``"removed"`` change. + """ + + path: str + kind: FieldChangeKind + old: Any = None + new: Any = None + + +@dataclass +class NodeChange: + """ + A change to a single pipeline component (node), identified by its ``id``. + + A component that exists on both sides may yield *two* ``NodeChange`` entries: + one with ``kind="provider"`` if its provider (node type) changed, and one + with ``kind="config"`` if its settings changed. This keeps ``kind`` a single + closed value while still expressing both facts. + + Attributes: + id: The component id (stable identity used to match nodes across pipes). + kind: The nature of the change: + - ``"added"``: node exists only in the new pipe (``provider_new`` set). + - ``"removed"``: node exists only in the old pipe (``provider_old`` set). + - ``"provider"``: node exists in both; its provider changed + (both ``provider_old`` and ``provider_new`` set). + - ``"config"``: node exists in both; one or more fields changed + (``field_changes`` non-empty). With layout opted in, ``field_changes`` + may include ``ui.*`` paths in addition to ``config.*`` paths. + provider_old: The component's provider before the change, when relevant. + provider_new: The component's provider after the change, when relevant. + field_changes: The dotted-path field changes for a ``"config"`` change. + """ + + id: str + kind: NodeChangeKind + provider_old: Optional[str] = None + provider_new: Optional[str] = None + field_changes: list[FieldChange] = field(default_factory=list) + + +@dataclass(frozen=True) +class EdgeChange: + """ + An added or removed directed wire between two components. + + Edges are reconstructed from each component's ``input[]`` lanes (data wiring) + and ``control[]`` entries (agent orchestration wiring). Both are directed + ``from`` a source component ``to`` the component that declares them, labelled + by a lane: the ``lane`` string for data edges, the ``classType`` string for + control edges (e.g. ``"llm"``, ``"tool"``, ``"memory"``). + + Attributes: + from_id: The source component id (the ``from`` value on the wire). + lane: The lane label (data ``lane`` or control ``classType``). + to_id: The destination component id (the component declaring the wire). + kind: ``"added"`` (only in the new pipe) or ``"removed"`` (only in the old). + """ + + from_id: str + lane: str + to_id: str + kind: EdgeChangeKind + + +@dataclass +class PipeDiff: + """ + The complete semantic difference between two ``.pipe`` pipelines. + + This is the single object handed to every reporter. It separates true + semantic change (nodes, edges, version) from cosmetic canvas churn + (``layout_changed``), which is the entire point of ``rocketride diff``. + + Attributes: + node_changes: All component-level changes, ordered deterministically + (added, then removed, then per-id provider/config changes). + edge_changes: All wiring changes (added edges first, then removed). + version_change: ``(old_version, new_version)`` when the top-level + ``version`` field differs, else ``None``. Always computed regardless + of layout options, since a version bump is semantically meaningful. + layout_changed: ``True`` when the top-level ``viewport`` or any retained + component's ``ui`` block differs. This is a coarse hint only; layout + details are enumerated as ``ui.*`` field changes only when the caller + opts layout in. + """ + + node_changes: list[NodeChange] = field(default_factory=list) + edge_changes: list[EdgeChange] = field(default_factory=list) + version_change: Optional[tuple[Any, Any]] = None + layout_changed: bool = False + + @property + def has_semantic_changes(self) -> bool: + """ + Whether this diff contains any change that should gate a non-zero exit. + + Returns ``True`` when there are node changes, edge changes, or a version + change. Pure layout churn (``layout_changed`` with no other change) does + **not** count as semantic, so a canvas-only move exits ``0``. + + Returns: + ``True`` if any semantic (non-layout) change is present. + """ + return bool(self.node_changes or self.edge_changes or self.version_change is not None) diff --git a/packages/client-python/src/rocketride/pipediff/reporters.py b/packages/client-python/src/rocketride/pipediff/reporters.py new file mode 100644 index 000000000..487d3ea6d --- /dev/null +++ b/packages/client-python/src/rocketride/pipediff/reporters.py @@ -0,0 +1,613 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Rendering utilities for semantic ``.pipe`` pipeline diffs. + +This module turns a :class:`~rocketride.pipediff.model.PipeDiff` (produced by the +pipediff engine) into three presentation formats used by the ``rocketride diff`` +command: + + - ``render_human`` : colored, section-grouped text for interactive terminals + - ``render_json`` : a single JSON-serializable document for tooling + - ``render_markdown`` : compact, PR-comment-friendly Markdown for the GitHub Action + +The renderers share a single normalization pass (``_organize``) so that every +format reports the same set of changes in the same deterministic order, +regardless of the order in which the engine emitted them. This determinism keeps +JSON output stable for snapshot tests and keeps PR comments from churning. + +Design notes: + - The renderers are intentionally *read-only* over the diff model: they never + construct model objects, so they depend on the model dataclasses for typing + only (guarded by ``TYPE_CHECKING``). This keeps the pipediff library free of + import-time coupling between the reporters and the engine, and lets the + module load even while sibling modules are still being written. + - ANSI color constants are defined locally rather than imported from + ``rocketride.cli.ui.colors``. The pipediff package is a library that the CLI + depends on; importing back into the CLI would invert that layering. The + constants below mirror the values in ``rocketride/cli/ui/colors.py``. + - Callers decide whether color is appropriate (TTY detection, ``NO_COLOR``) + and pass the result via ``use_color``; the renderer itself never inspects + the environment. + +Components: + render_human: Human-readable, optionally colored diff report + render_json: JSON-serializable diff document with a summary block + render_markdown: Compact Markdown diff suitable for embedding in PR comments +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple + +if TYPE_CHECKING: # pragma: no cover - typing-only imports + from .model import PipeDiff + + +# ANSI color codes. These mirror rocketride/cli/ui/colors.py; they are duplicated +# here so the pipediff library does not import from the CLI package (which would +# invert the dependency direction, since the CLI depends on pipediff). +_ANSI_RESET = '\033[0m' +_ANSI_RED = '\033[91m' +_ANSI_GREEN = '\033[92m' +_ANSI_YELLOW = '\033[93m' +_ANSI_GRAY = '\033[90m' + +# Change markers shared across all output formats. +_MARK_ADDED = '+' +_MARK_REMOVED = '-' +_MARK_CHANGED = '~' + +# Markdown marker glyphs (kept ASCII-safe and unambiguous in PR comments). +_MD_ADDED = '+' +_MD_REMOVED = '-' +_MD_CHANGED = '~' + + +class _Palette: + """ + Small helper that applies (or suppresses) ANSI color per change kind. + + When ``use_color`` is false every method returns its argument unchanged, so + the same rendering code path produces plain text for non-TTY output and for + ``NO_COLOR`` environments without branching at each call site. + """ + + def __init__(self, use_color: bool): + """Store whether color escapes should be emitted.""" + self._on = bool(use_color) + + def _wrap(self, code: str, text: str) -> str: + """Wrap ``text`` in an ANSI ``code``/reset pair when color is enabled.""" + if not self._on: + return text + return f'{code}{text}{_ANSI_RESET}' + + def added(self, text: str) -> str: + """Color ``text`` as an addition (green).""" + return self._wrap(_ANSI_GREEN, text) + + def removed(self, text: str) -> str: + """Color ``text`` as a removal (red).""" + return self._wrap(_ANSI_RED, text) + + def changed(self, text: str) -> str: + """Color ``text`` as a modification (yellow).""" + return self._wrap(_ANSI_YELLOW, text) + + def dim(self, text: str) -> str: + """Color ``text`` as secondary/dim (gray).""" + return self._wrap(_ANSI_GRAY, text) + + +# ========================================================================= +# NORMALIZATION +# ========================================================================= + + +def _provider_added(node_change: Any) -> Optional[str]: + """Return the provider to display for an added node.""" + return node_change.provider_new if node_change.provider_new is not None else node_change.provider_old + + +def _provider_removed(node_change: Any) -> Optional[str]: + """Return the provider to display for a removed node.""" + return node_change.provider_old if node_change.provider_old is not None else node_change.provider_new + + +def _organize(diff: 'PipeDiff') -> Dict[str, Any]: + """ + Collapse a :class:`PipeDiff` into a sorted, de-duplicated intermediate form. + + All three renderers consume this structure so that they agree on both the set + of reported changes and their ordering. Node changes are aggregated by id: a + node that has both a provider change and configuration changes (whether the + engine emitted them as one ``NodeChange`` or several) is merged into a single + ``changed`` entry. + + Args: + diff: The pipeline diff to normalize. + + Returns: + A dictionary with the following keys: + - ``nodes_added``: list of ``(id, provider)`` tuples, sorted by id + - ``nodes_removed``: list of ``(id, provider)`` tuples, sorted by id + - ``nodes_changed``: list of per-id dicts (provider change + config + field changes), sorted by id + - ``edges_added``: list of ``(from_id, lane, to_id)`` tuples, sorted + - ``edges_removed``: list of ``(from_id, lane, to_id)`` tuples, sorted + - ``version_change``: the ``(old, new)`` tuple or ``None`` + - ``layout_changed``: bool + - ``has_semantic_changes``: bool (delegates to the diff's property) + """ + nodes_added: List[Tuple[str, Optional[str]]] = [] + nodes_removed: List[Tuple[str, Optional[str]]] = [] + changed: Dict[str, Dict[str, Any]] = {} + + for node_change in diff.node_changes: + kind = node_change.kind + if kind == 'added': + nodes_added.append((node_change.id, _provider_added(node_change))) + elif kind == 'removed': + nodes_removed.append((node_change.id, _provider_removed(node_change))) + else: + # 'provider', 'config', or any future in-place change kind: fold into + # a single per-id entry keyed by node id. + entry = changed.setdefault( + node_change.id, + { + 'id': node_change.id, + 'provider_old': None, + 'provider_new': None, + 'has_provider_change': False, + 'config_changes': [], + }, + ) + if ( + node_change.provider_old is not None or node_change.provider_new is not None + ) and node_change.provider_old != node_change.provider_new: + entry['provider_old'] = node_change.provider_old + entry['provider_new'] = node_change.provider_new + entry['has_provider_change'] = True + for field_change in node_change.field_changes or []: + entry['config_changes'].append(field_change) + + # Sort config changes within each node for deterministic output. + nodes_changed: List[Dict[str, Any]] = [] + for entry in changed.values(): + entry['config_changes'] = sorted( + entry['config_changes'], + key=lambda fc: (fc.path, fc.kind), + ) + nodes_changed.append(entry) + + edges_added: List[Tuple[str, str, str]] = [] + edges_removed: List[Tuple[str, str, str]] = [] + for edge_change in diff.edge_changes: + triple = (edge_change.from_id, edge_change.lane, edge_change.to_id) + if edge_change.kind == 'added': + edges_added.append(triple) + elif edge_change.kind == 'removed': + edges_removed.append(triple) + + return { + 'nodes_added': sorted(nodes_added, key=lambda t: t[0]), + 'nodes_removed': sorted(nodes_removed, key=lambda t: t[0]), + 'nodes_changed': sorted(nodes_changed, key=lambda e: e['id']), + 'edges_added': sorted(edges_added), + 'edges_removed': sorted(edges_removed), + 'version_change': diff.version_change, + 'layout_changed': bool(diff.layout_changed), + 'has_semantic_changes': bool(diff.has_semantic_changes), + } + + +def _any_output(org: Dict[str, Any]) -> bool: + """Return True when there is anything at all worth printing.""" + return bool( + org['nodes_added'] + or org['nodes_removed'] + or org['nodes_changed'] + or org['edges_added'] + or org['edges_removed'] + or org['version_change'] + or org['layout_changed'] + ) + + +def _fmt_value(value: Any) -> str: + """ + Render a config value as a compact, readable string. + + Strings are returned bare; every other JSON type (numbers, booleans, ``None``, + dicts, lists) is serialized with ``json.dumps`` so the type stays unambiguous + (``null``/``true``/``false``) and nested structures render deterministically. + """ + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False, sort_keys=True) + + +def _summary_counts(org: Dict[str, Any]) -> Dict[str, int]: + """Compute the integer counts used by summary lines and the JSON summary.""" + config_changes = sum(len(entry['config_changes']) for entry in org['nodes_changed']) + provider_changes = sum(1 for entry in org['nodes_changed'] if entry['has_provider_change']) + return { + 'nodes_added': len(org['nodes_added']), + 'nodes_removed': len(org['nodes_removed']), + 'nodes_changed': len(org['nodes_changed']), + 'provider_changes': provider_changes, + 'config_changes': config_changes, + 'edges_added': len(org['edges_added']), + 'edges_removed': len(org['edges_removed']), + } + + +def _summary_phrase(org: Dict[str, Any]) -> str: + """ + Build a one-line human summary such as ``2 nodes added, 1 edge removed``. + + Only non-zero categories are included. Version and layout changes are appended + when present. When nothing changed the phrase is ``no semantic changes``. + """ + counts = _summary_counts(org) + parts: List[str] = [] + + def _plural(n: int, singular: str) -> str: + return f'{n} {singular}' if n == 1 else f'{n} {singular}s' + + if counts['nodes_added']: + parts.append(f'{_plural(counts["nodes_added"], "node")} added') + if counts['nodes_removed']: + parts.append(f'{_plural(counts["nodes_removed"], "node")} removed') + if counts['nodes_changed']: + parts.append(f'{_plural(counts["nodes_changed"], "node")} changed') + if counts['edges_added']: + parts.append(f'{_plural(counts["edges_added"], "edge")} added') + if counts['edges_removed']: + parts.append(f'{_plural(counts["edges_removed"], "edge")} removed') + + version_change = org['version_change'] + if version_change: + parts.append(f'version {_fmt_value(version_change[0])} → {_fmt_value(version_change[1])}') + + if org['layout_changed']: + parts.append('layout changed') + + if not parts: + return 'no semantic changes' + return ', '.join(parts) + + +# ========================================================================= +# HUMAN (TEXT) RENDERER +# ========================================================================= + + +def _human_field_line(field_change: Any, palette: _Palette) -> str: + """Render a single config FieldChange as an indented, marked text line.""" + path = field_change.path + kind = field_change.kind + if kind == 'added': + body = f'{_MARK_ADDED} {path} = {_fmt_value(field_change.new)}' + return ' ' + palette.added(body) + if kind == 'removed': + body = f'{_MARK_REMOVED} {path} = {_fmt_value(field_change.old)}' + return ' ' + palette.removed(body) + # changed + old = _fmt_value(field_change.old) + new = _fmt_value(field_change.new) + return ' ' + palette.changed(f'{_MARK_CHANGED} {path}: ') + f'{old} ' + palette.dim('->') + f' {new}' + + +def render_human(diff: 'PipeDiff', *, use_color: bool) -> str: + """ + Render a semantic pipe diff as grouped, optionally colored text. + + The report is organized into up to three sections — ``Nodes`` (additions, + removals, provider changes), ``Edges`` (added/removed wiring), and ``Config`` + (per-node field changes) — followed by standalone ``Version`` and ``Layout`` + lines when applicable. Change markers are ``+`` (added, green), ``-`` (removed, + red), and ``~`` (changed, yellow). Empty sections are omitted. + + Args: + diff: The pipeline diff to render. + use_color: When True, wrap markers and values in ANSI color escapes. The + caller is responsible for deciding whether color is appropriate (TTY + detection and ``NO_COLOR`` honoring live in the CLI); this function + only applies the choice. + + Returns: + A newline-joined string with no trailing newline. When there are no + changes at all the string is ``No semantic changes.``. + """ + org = _organize(diff) + palette = _Palette(use_color) + + if not _any_output(org): + return 'No semantic changes.' + + lines: List[str] = [] + lines.append(f'Pipeline diff: {_summary_phrase(org)}') + + # --- Nodes --- + if org['nodes_added'] or org['nodes_removed'] or any(e['has_provider_change'] for e in org['nodes_changed']): + lines.append('') + lines.append('Nodes') + for node_id, provider in org['nodes_added']: + lines.append(' ' + palette.added(f'{_MARK_ADDED} {node_id} ({provider})')) + for node_id, provider in org['nodes_removed']: + lines.append(' ' + palette.removed(f'{_MARK_REMOVED} {node_id} ({provider})')) + for entry in org['nodes_changed']: + if entry['has_provider_change']: + marker = palette.changed(f'{_MARK_CHANGED} {entry["id"]} provider: ') + lines.append( + ' ' + marker + f'{entry["provider_old"]} ' + palette.dim('->') + f' {entry["provider_new"]}' + ) + + # --- Edges --- + if org['edges_added'] or org['edges_removed']: + lines.append('') + lines.append('Edges') + for from_id, lane, to_id in org['edges_added']: + lines.append(' ' + palette.added(f'{_MARK_ADDED} {from_id} --{lane}--> {to_id}')) + for from_id, lane, to_id in org['edges_removed']: + lines.append(' ' + palette.removed(f'{_MARK_REMOVED} {from_id} --{lane}--> {to_id}')) + + # --- Config --- + config_nodes = [e for e in org['nodes_changed'] if e['config_changes']] + if config_nodes: + lines.append('') + lines.append('Config') + for entry in config_nodes: + lines.append(f' {entry["id"]}') + for field_change in entry['config_changes']: + lines.append(_human_field_line(field_change, palette)) + + # --- Version / Layout --- + if org['version_change']: + old = _fmt_value(org['version_change'][0]) + new = _fmt_value(org['version_change'][1]) + lines.append('') + lines.append('Version: ' + f'{old} ' + palette.dim('->') + f' {new}') + + if org['layout_changed']: + if not org['version_change']: + lines.append('') + lines.append(palette.dim('Layout: changed (ui/viewport)')) + + return '\n'.join(lines) + + +# ========================================================================= +# JSON RENDERER +# ========================================================================= + + +def _json_field_change(field_change: Any) -> Dict[str, Any]: + """Serialize a single FieldChange into a plain dict.""" + return { + 'path': field_change.path, + 'kind': field_change.kind, + 'old': field_change.old, + 'new': field_change.new, + } + + +def render_json(diff: 'PipeDiff') -> Dict[str, Any]: + """ + Render a semantic pipe diff as a single JSON-serializable document. + + The returned structure is stable and deterministically ordered: + + { + "nodes": {"added": [...], "removed": [...], "changed": [...]}, + "edges": {"added": [...], "removed": [...]}, + "summary": {...} + } + + Each added/removed node is ``{"id", "provider"}``. Each changed node is + ``{"id", "provider_change": {"old", "new"} | null, "config_changes": [...]}`` + where every config change is ``{"path", "kind", "old", "new"}``. Edges are + ``{"from", "lane", "to"}``. The ``summary`` block carries counts, the version + change (as ``[old, new]`` or ``null``), the layout flag, and the overall + ``has_semantic_changes`` boolean. + + Args: + diff: The pipeline diff to render. + + Returns: + A dict containing only JSON-serializable values. + """ + org = _organize(diff) + + nodes_added = [{'id': node_id, 'provider': provider} for node_id, provider in org['nodes_added']] + nodes_removed = [{'id': node_id, 'provider': provider} for node_id, provider in org['nodes_removed']] + + nodes_changed: List[Dict[str, Any]] = [] + for entry in org['nodes_changed']: + provider_change = None + if entry['has_provider_change']: + provider_change = {'old': entry['provider_old'], 'new': entry['provider_new']} + nodes_changed.append( + { + 'id': entry['id'], + 'provider_change': provider_change, + 'config_changes': [_json_field_change(fc) for fc in entry['config_changes']], + } + ) + + edges_added = [{'from': from_id, 'lane': lane, 'to': to_id} for from_id, lane, to_id in org['edges_added']] + edges_removed = [{'from': from_id, 'lane': lane, 'to': to_id} for from_id, lane, to_id in org['edges_removed']] + + counts = _summary_counts(org) + version_change = list(org['version_change']) if org['version_change'] else None + + summary = { + **counts, + 'version_change': version_change, + 'layout_changed': org['layout_changed'], + 'has_semantic_changes': org['has_semantic_changes'], + } + + return { + 'nodes': {'added': nodes_added, 'removed': nodes_removed, 'changed': nodes_changed}, + 'edges': {'added': edges_added, 'removed': edges_removed}, + 'summary': summary, + } + + +# ========================================================================= +# MARKDOWN RENDERER +# ========================================================================= + + +def _md_code(text: str) -> str: + """ + Wrap ``text`` in a Markdown code span, safely handling embedded backticks. + + Per CommonMark, a code span may use a run of N backticks as its delimiter as + long as the content contains no run of exactly N backticks; padding spaces are + stripped by the renderer. This picks a delimiter longer than the longest + internal backtick run so arbitrary values render literally. + + Line breaks are collapsed to spaces first. A CommonMark code span cannot span a + blank line, so a newline in an untrusted value (node id, provider, lane, config + value) would otherwise terminate the span and the enclosing bullet/table cell, + letting the trailing text render as real Markdown (headings, ``@`` mentions, + links) in the auto-posted PR comment. Neutralizing them here — the single choke + point every value passes through — covers bullets, table cells, and the version + line at once. + """ + text = str(text).replace('\r', ' ').replace('\n', ' ') + if '`' not in text: + return f'`{text}`' + longest = max(len(run) for run in re.findall(r'`+', text)) + fence = '`' * (longest + 1) + return f'{fence} {text} {fence}' + + +def _md_cell(text: str) -> str: + """ + Escape a string for use inside a Markdown table cell. + + GitHub's table parser splits on ``|`` even inside code spans, and literal + newlines break the row, so both are neutralized here after any code-span + formatting has been applied. + """ + return str(text).replace('\\', '\\\\').replace('|', '\\|').replace('\n', ' ') + + +def _md_field_change(field_change: Any) -> str: + """Render a config FieldChange as a compact Markdown 'change' fragment.""" + if field_change.kind == 'added': + return f'{_MD_ADDED} {_md_code(field_change.new)}' + if field_change.kind == 'removed': + return f'{_MD_REMOVED} {_md_code(field_change.old)}' + return f'{_md_code(field_change.old)} → {_md_code(field_change.new)}' + + +def render_markdown(diff: 'PipeDiff', *, title: Optional[str] = None) -> str: + """ + Render a semantic pipe diff as compact, PR-comment-friendly Markdown. + + The output leads with a bold one-line summary, then grouped sections: ``Nodes`` + and ``Edges`` as bullet lists and ``Config`` as a table (``Node | Field | + Change``). All node ids, providers, lanes, and values are wrapped in code + spans; table cells additionally escape ``|`` and newlines so untrusted config + values cannot break the table or the surrounding comment. Ordering is + deterministic, so re-running on unchanged input yields byte-identical output. + + Args: + diff: The pipeline diff to render. + title: Optional heading text. When provided it is emitted as an ``##`` + heading above the summary; otherwise no heading is emitted. + + Returns: + A Markdown string with no trailing newline. + """ + org = _organize(diff) + + lines: List[str] = [] + if title: + lines.append(f'## {title}') + lines.append('') + + lines.append(f'**Pipeline diff:** {_summary_phrase(org)}') + + if not _any_output(org): + return '\n'.join(lines) + + # --- Nodes --- + provider_changed = [e for e in org['nodes_changed'] if e['has_provider_change']] + if org['nodes_added'] or org['nodes_removed'] or provider_changed: + lines.append('') + lines.append('**Nodes**') + for node_id, provider in org['nodes_added']: + lines.append(f'- {_MD_ADDED} {_md_code(node_id)} ({_md_code(provider)})') + for node_id, provider in org['nodes_removed']: + lines.append(f'- {_MD_REMOVED} {_md_code(node_id)} ({_md_code(provider)})') + for entry in provider_changed: + lines.append( + f'- {_MD_CHANGED} {_md_code(entry["id"])} provider: ' + f'{_md_code(entry["provider_old"])} → {_md_code(entry["provider_new"])}' + ) + + # --- Edges --- + if org['edges_added'] or org['edges_removed']: + lines.append('') + lines.append('**Edges**') + for from_id, lane, to_id in org['edges_added']: + lines.append(f'- {_MD_ADDED} {_md_code(from_id)} --{_md_code(lane)}--> {_md_code(to_id)}') + for from_id, lane, to_id in org['edges_removed']: + lines.append(f'- {_MD_REMOVED} {_md_code(from_id)} --{_md_code(lane)}--> {_md_code(to_id)}') + + # --- Config (table) --- + config_nodes = [e for e in org['nodes_changed'] if e['config_changes']] + if config_nodes: + lines.append('') + lines.append('**Config**') + lines.append('') + lines.append('| Node | Field | Change |') + lines.append('| --- | --- | --- |') + for entry in config_nodes: + for field_change in entry['config_changes']: + node_cell = _md_cell(_md_code(entry['id'])) + field_cell = _md_cell(_md_code(field_change.path)) + change_cell = _md_cell(_md_field_change(field_change)) + lines.append(f'| {node_cell} | {field_cell} | {change_cell} |') + + # --- Version / Layout --- + if org['version_change']: + old = _md_code(_fmt_value(org['version_change'][0])) + new = _md_code(_fmt_value(org['version_change'][1])) + lines.append('') + lines.append(f'**Version:** {old} → {new}') + + if org['layout_changed']: + lines.append('') + lines.append('_Layout (ui/viewport) changed._') + + return '\n'.join(lines) diff --git a/packages/client-python/tests/test_pipediff_engine.py b/packages/client-python/tests/test_pipediff_engine.py new file mode 100644 index 000000000..0173e1caf --- /dev/null +++ b/packages/client-python/tests/test_pipediff_engine.py @@ -0,0 +1,426 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Unit tests for the RocketRide pipe-diff engine, model, and config deep-diff.""" + +import copy +import json +from pathlib import Path + +import pytest + +from rocketride.pipediff import ( + EdgeChange, + PipeDiffError, + deep_diff_config, + diff_pipes, + load_pipe, +) + + +# --------------------------------------------------------------------------- +# Fixtures / builders +# --------------------------------------------------------------------------- + + +@pytest.fixture +def examples_dir(): + """Path to the repo's bundled ``examples/`` directory of ``.pipe`` files.""" + # tests/ -> client-python/ -> packages/ -> repo root -> examples/ + return Path(__file__).resolve().parents[3] / 'examples' + + +def _node(component_id, provider, config=None, inputs=None, control=None, ui=None): + """Build a single component dict, omitting empty optional blocks.""" + component = {'id': component_id, 'provider': provider, 'config': config or {}} + if inputs is not None: + component['input'] = inputs + if control is not None: + component['control'] = control + component['ui'] = ui if ui is not None else {'position': {'x': 0, 'y': 0}} + return component + + +def _pipe(components, version=1, viewport=None): + """Build a top-level pipe dict.""" + pipe = {'components': components, 'project_id': 'p', 'version': version} + if viewport is not None: + pipe['viewport'] = viewport + return pipe + + +def _sample_pipe(): + """A small but representative two-node RAG-ish pipeline.""" + return _pipe( + [ + _node('webhook_1', 'webhook', {'mode': 'Source', 'type': 'webhook'}), + _node( + 'parse_1', + 'parse', + {'profile': 'default', 'default': {'strlen': 512}}, + inputs=[{'lane': 'text', 'from': 'webhook_1'}], + ), + ] + ) + + +def _changes_by_kind(diff, kind): + return [change for change in diff.node_changes if change.kind == kind] + + +# --------------------------------------------------------------------------- +# Identity / no-change +# --------------------------------------------------------------------------- + + +def test_identical_pipes_produce_empty_diff(): + pipe = _sample_pipe() + diff = diff_pipes(pipe, copy.deepcopy(pipe)) + assert diff.node_changes == [] + assert diff.edge_changes == [] + assert diff.version_change is None + assert diff.layout_changed is False + assert diff.has_semantic_changes is False + + +# --------------------------------------------------------------------------- +# Nodes +# --------------------------------------------------------------------------- + + +def test_added_node_is_reported_with_provider(): + old = _sample_pipe() + new = copy.deepcopy(old) + new['components'].append(_node('llm_1', 'llm_openai', inputs=[{'lane': 'questions', 'from': 'parse_1'}])) + diff = diff_pipes(old, new) + + added = _changes_by_kind(diff, 'added') + assert [c.id for c in added] == ['llm_1'] + assert added[0].provider_new == 'llm_openai' + assert added[0].provider_old is None + assert diff.has_semantic_changes is True + + +def test_removed_node_is_reported_with_provider(): + old = _sample_pipe() + new = copy.deepcopy(old) + new['components'] = [c for c in new['components'] if c['id'] != 'parse_1'] + diff = diff_pipes(old, new) + + removed = _changes_by_kind(diff, 'removed') + assert [c.id for c in removed] == ['parse_1'] + assert removed[0].provider_old == 'parse' + assert removed[0].provider_new is None + + +def test_provider_change_is_reported_separately_from_config(): + old = _sample_pipe() + new = copy.deepcopy(old) + # Same id, different provider *and* a config change. + new['components'][1]['provider'] = 'parse_v2' + new['components'][1]['config']['default']['strlen'] = 1024 + diff = diff_pipes(old, new) + + provider_changes = _changes_by_kind(diff, 'provider') + assert len(provider_changes) == 1 + assert provider_changes[0].id == 'parse_1' + assert provider_changes[0].provider_old == 'parse' + assert provider_changes[0].provider_new == 'parse_v2' + + config_changes = _changes_by_kind(diff, 'config') + assert len(config_changes) == 1 + assert config_changes[0].id == 'parse_1' + paths = {fc.path for fc in config_changes[0].field_changes} + assert paths == {'config.default.strlen'} + + +# --------------------------------------------------------------------------- +# Config deep-diff +# --------------------------------------------------------------------------- + + +def test_deep_diff_config_handles_nested_dicts_added_removed_changed(): + old = {'profile': 'a', 'default': {'strlen': 512, 'gone': True}} + new = {'profile': 'b', 'default': {'strlen': 1024, 'added_key': 7}} + changes = {(fc.path, fc.kind): (fc.old, fc.new) for fc in deep_diff_config(old, new)} + + assert changes[('config.profile', 'changed')] == ('a', 'b') + assert changes[('config.default.strlen', 'changed')] == (512, 1024) + assert changes[('config.default.gone', 'removed')] == (True, None) + assert changes[('config.default.added_key', 'added')] == (None, 7) + + +def test_deep_diff_config_handles_list_element_change(): + old = {'instructions': ['Answer accurately.', 'Be terse.']} + new = {'instructions': ['Answer accurately and cite sources.', 'Be terse.']} + changes = deep_diff_config(old, new) + + assert len(changes) == 1 + change = changes[0] + assert change.path == 'config.instructions[0]' + assert change.kind == 'changed' + assert change.old == 'Answer accurately.' + assert change.new == 'Answer accurately and cite sources.' + + +def test_deep_diff_config_handles_list_growth_and_shrink(): + grow = deep_diff_config({'xs': [1]}, {'xs': [1, 2, 3]}) + assert {(c.path, c.kind, c.new) for c in grow} == { + ('config.xs[1]', 'added', 2), + ('config.xs[2]', 'added', 3), + } + + shrink = deep_diff_config({'xs': [1, 2, 3]}, {'xs': [1]}) + assert {(c.path, c.kind, c.old) for c in shrink} == { + ('config.xs[1]', 'removed', 2), + ('config.xs[2]', 'removed', 3), + } + + +def test_deep_diff_config_handles_list_of_dicts_index_paths(): + old = {'steps': [{'name': 'a', 'n': 1}]} + new = {'steps': [{'name': 'a', 'n': 2}]} + changes = deep_diff_config(old, new) + assert len(changes) == 1 + assert changes[0].path == 'config.steps[0].n' + assert (changes[0].old, changes[0].new) == (1, 2) + + +def test_deep_diff_config_equal_returns_empty(): + cfg = {'a': {'b': [1, 2, {'c': 3}]}} + assert deep_diff_config(cfg, copy.deepcopy(cfg)) == [] + + +def test_deep_diff_config_treats_none_as_empty(): + assert deep_diff_config(None, {'a': 1}) == [ + # single added key + *deep_diff_config({}, {'a': 1}) + ] + assert deep_diff_config(None, None) == [] + + +# --------------------------------------------------------------------------- +# Edges (input + control) +# --------------------------------------------------------------------------- + + +def test_edge_added_and_removed(): + old = _sample_pipe() + new = copy.deepcopy(old) + # Rewire parse_1 to read from a new lane/source, and add a fresh node+edge. + new['components'][1]['input'] = [{'lane': 'raw', 'from': 'webhook_1'}] + diff = diff_pipes(old, new) + + added = [e for e in diff.edge_changes if e.kind == 'added'] + removed = [e for e in diff.edge_changes if e.kind == 'removed'] + assert added == [EdgeChange(from_id='webhook_1', lane='raw', to_id='parse_1', kind='added')] + assert removed == [EdgeChange(from_id='webhook_1', lane='text', to_id='parse_1', kind='removed')] + + +def test_edge_rewire_shows_as_added_and_removed_pair(): + old = _pipe( + [ + _node('a', 'src'), + _node('b', 'src'), + _node('c', 'sink', inputs=[{'lane': 'x', 'from': 'a'}]), + ] + ) + new = copy.deepcopy(old) + # Rewire c's lane x from a -> b (same to/lane, different from). + new['components'][2]['input'] = [{'lane': 'x', 'from': 'b'}] + diff = diff_pipes(old, new) + + assert EdgeChange('b', 'x', 'c', 'added') in diff.edge_changes + assert EdgeChange('a', 'x', 'c', 'removed') in diff.edge_changes + assert len(diff.edge_changes) == 2 + + +def test_control_edges_are_diffed(): + old = _pipe( + [ + _node('agent_1', 'agent_rocketride'), + _node('llm_1', 'llm_openai', control=[{'classType': 'llm', 'from': 'agent_1'}]), + _node('tool_1', 'tool_python', control=[{'classType': 'tool', 'from': 'agent_1'}]), + ] + ) + new = copy.deepcopy(old) + # Detach the tool from the agent (remove its control wire). + new['components'][2].pop('control') + diff = diff_pipes(old, new) + + removed = [e for e in diff.edge_changes if e.kind == 'removed'] + assert removed == [EdgeChange(from_id='agent_1', lane='tool', to_id='tool_1', kind='removed')] + # The surviving llm control wire produces no change. + assert not [e for e in diff.edge_changes if e.kind == 'added'] + + +# --------------------------------------------------------------------------- +# Version +# --------------------------------------------------------------------------- + + +def test_version_change_is_reported_and_is_semantic(): + old = _sample_pipe() + new = copy.deepcopy(old) + new['version'] = 2 + diff = diff_pipes(old, new) + assert diff.version_change == (1, 2) + assert diff.has_semantic_changes is True + + +# --------------------------------------------------------------------------- +# Layout +# --------------------------------------------------------------------------- + + +def test_layout_only_change_is_not_semantic_by_default(): + old = _sample_pipe() + new = copy.deepcopy(old) + new['components'][1]['ui']['position'] = {'x': 999, 'y': 42} + diff = diff_pipes(old, new) + + assert diff.node_changes == [] + assert diff.edge_changes == [] + assert diff.layout_changed is True + assert diff.has_semantic_changes is False + + +def test_viewport_only_change_sets_layout_flag_but_is_not_semantic(): + old = _pipe([_node('a', 'src')], viewport={'x': 0, 'y': 0, 'zoom': 1}) + new = copy.deepcopy(old) + new['viewport'] = {'x': 10, 'y': 20, 'zoom': 2} + diff = diff_pipes(old, new) + + assert diff.layout_changed is True + assert diff.has_semantic_changes is False + + +def test_include_layout_surfaces_ui_field_changes(): + old = _sample_pipe() + new = copy.deepcopy(old) + new['components'][1]['ui']['position']['x'] = 999 + diff = diff_pipes(old, new, include_layout=True) + + config_changes = _changes_by_kind(diff, 'config') + assert len(config_changes) == 1 + paths = {fc.path for fc in config_changes[0].field_changes} + assert 'ui.position.x' in paths + # With layout opted in, the ui move now counts as a change. + assert diff.has_semantic_changes is True + # The flag is still set regardless of the include_layout mode. + assert diff.layout_changed is True + + +# --------------------------------------------------------------------------- +# Determinism +# --------------------------------------------------------------------------- + + +def test_node_change_ordering_is_deterministic(): + old = _pipe([_node('keep', 'src'), _node('zeta', 'src'), _node('alpha', 'src')]) + new = _pipe([_node('keep', 'src'), _node('beta', 'src'), _node('gamma', 'src')]) + diff = diff_pipes(old, new) + + added = [c.id for c in diff.node_changes if c.kind == 'added'] + removed = [c.id for c in diff.node_changes if c.kind == 'removed'] + assert added == ['beta', 'gamma'] # sorted + assert removed == ['alpha', 'zeta'] # sorted + + +# --------------------------------------------------------------------------- +# load_pipe validation +# --------------------------------------------------------------------------- + + +def test_load_pipe_from_path_roundtrip(tmp_path): + pipe = _sample_pipe() + path = tmp_path / 'pipeline.pipe' + path.write_text(json.dumps(pipe), encoding='utf-8') + assert load_pipe(str(path)) == pipe + + +def test_load_pipe_accepts_dict_passthrough(): + pipe = _sample_pipe() + assert load_pipe(pipe) is pipe + + +def test_load_pipe_missing_file_raises(): + with pytest.raises(PipeDiffError, match='not found'): + load_pipe('/nonexistent/path/to/file.pipe') + + +def test_load_pipe_malformed_json_raises(tmp_path): + path = tmp_path / 'bad.pipe' + path.write_text('{ not valid json', encoding='utf-8') + with pytest.raises(PipeDiffError, match='Invalid JSON'): + load_pipe(str(path)) + + +def test_load_pipe_non_object_raises(tmp_path): + path = tmp_path / 'list.pipe' + path.write_text('[1, 2, 3]', encoding='utf-8') + with pytest.raises(PipeDiffError, match='must be a JSON object'): + load_pipe(str(path)) + + +def test_load_pipe_missing_components_raises(): + with pytest.raises(PipeDiffError, match="missing a 'components' list"): + load_pipe({'project_id': 'p', 'version': 1}) + + +def test_load_pipe_components_not_a_list_raises(): + with pytest.raises(PipeDiffError, match="missing a 'components' list"): + load_pipe({'components': {'id': 'x'}}) + + +def test_load_pipe_component_missing_id_raises(): + with pytest.raises(PipeDiffError, match="index 0 is missing a string 'id'"): + load_pipe({'components': [{'provider': 'webhook'}]}) + + +def test_load_pipe_component_not_object_raises(): + with pytest.raises(PipeDiffError, match='index 0 is not an object'): + load_pipe({'components': ['not-a-dict']}) + + +def test_load_pipe_rejects_unsupported_type(): + with pytest.raises(PipeDiffError, match='expects a path or dict'): + load_pipe(12345) + + +# --------------------------------------------------------------------------- +# End-to-end against a real bundled example +# --------------------------------------------------------------------------- + + +def test_diff_of_real_example_against_itself_is_empty(examples_dir): + path = examples_dir / 'rag-pipeline.pipe' + if not path.exists(): + pytest.skip('bundled example not available') + pipe = load_pipe(str(path)) + diff = diff_pipes(pipe, copy.deepcopy(pipe)) + assert diff.has_semantic_changes is False + assert diff.layout_changed is False + + +if __name__ == '__main__': + raise SystemExit(pytest.main([__file__, '-v'])) diff --git a/packages/client-python/tests/test_pipediff_gitref.py b/packages/client-python/tests/test_pipediff_gitref.py new file mode 100644 index 000000000..b9033e21b --- /dev/null +++ b/packages/client-python/tests/test_pipediff_gitref.py @@ -0,0 +1,194 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Tests for the git ref resolver used by ``rocketride diff --git``. + +Most cases stub ``subprocess.run`` so no real git or repository is needed; a +single, git-gated integration test exercises the real ``git show`` plumbing in +an isolated temporary repository. +""" + +import json +import shutil +import subprocess + +import pytest + +from rocketride.pipediff import PipeDiffError, gitref, resolve_git_ref + +SAMPLE_PIPE = { + 'components': [{'id': 'a', 'provider': 'src', 'config': {}}], + 'version': 1, +} + + +def _completed(returncode, stdout='', stderr=''): + """Build a stub ``CompletedProcess`` result.""" + return subprocess.CompletedProcess(args=['git'], returncode=returncode, stdout=stdout, stderr=stderr) + + +def _install_fake_git(monkeypatch, responses): + """Patch ``subprocess.run`` in gitref to reply per git subcommand. + + ``responses`` maps the subcommand ("rev-parse" or "show") to either a + ``CompletedProcess`` or an ``Exception`` instance (which is raised). + """ + + def fake_run(args, **_kwargs): + subcommand = args[3] # ['git', '-C', , , ...] + outcome = responses[subcommand] + if isinstance(outcome, BaseException): + raise outcome + return outcome + + monkeypatch.setattr(gitref.subprocess, 'run', fake_run) + + +# --------------------------------------------------------------------------- +# Mocked subprocess cases +# --------------------------------------------------------------------------- + + +def test_resolve_returns_parsed_pipe_when_found(monkeypatch): + _install_fake_git( + monkeypatch, + { + 'rev-parse': _completed(0, stdout='/repo\n'), + 'show': _completed(0, stdout=json.dumps(SAMPLE_PIPE)), + }, + ) + assert resolve_git_ref('HEAD', '/repo/pipeline.pipe') == SAMPLE_PIPE + + +@pytest.mark.parametrize( + 'stderr', + [ + "fatal: path 'pipeline.pipe' exists on disk, but not in 'HEAD'", + "fatal: path 'pipeline.pipe' does not exist in 'HEAD'", + ], +) +def test_resolve_returns_none_when_path_absent_in_ref(monkeypatch, stderr): + _install_fake_git( + monkeypatch, + { + 'rev-parse': _completed(0, stdout='/repo\n'), + 'show': _completed(128, stderr=stderr), + }, + ) + assert resolve_git_ref('HEAD', '/repo/pipeline.pipe') is None + + +def test_resolve_raises_on_unknown_ref(monkeypatch): + _install_fake_git( + monkeypatch, + { + 'rev-parse': _completed(0, stdout='/repo\n'), + 'show': _completed(128, stderr="fatal: invalid object name 'nope'."), + }, + ) + with pytest.raises(PipeDiffError, match='git show'): + resolve_git_ref('nope', '/repo/pipeline.pipe') + + +def test_resolve_raises_when_not_a_git_repo(monkeypatch): + _install_fake_git( + monkeypatch, + { + 'rev-parse': _completed(128, stderr='fatal: not a git repository (or any parent up to /)'), + }, + ) + with pytest.raises(PipeDiffError, match='Not a git repository'): + resolve_git_ref('HEAD', '/tmp/pipeline.pipe') + + +def test_resolve_raises_when_git_missing(monkeypatch): + _install_fake_git(monkeypatch, {'rev-parse': FileNotFoundError('git')}) + with pytest.raises(PipeDiffError, match='git executable not found'): + resolve_git_ref('HEAD', '/repo/pipeline.pipe') + + +def test_resolve_raises_on_timeout(monkeypatch): + _install_fake_git( + monkeypatch, + {'rev-parse': subprocess.TimeoutExpired(cmd='git', timeout=30)}, + ) + with pytest.raises(PipeDiffError, match='timed out'): + resolve_git_ref('HEAD', '/repo/pipeline.pipe') + + +def test_resolve_raises_on_invalid_json_content(monkeypatch): + _install_fake_git( + monkeypatch, + { + 'rev-parse': _completed(0, stdout='/repo\n'), + 'show': _completed(0, stdout='{ not json'), + }, + ) + with pytest.raises(PipeDiffError, match='Invalid JSON'): + resolve_git_ref('HEAD', '/repo/pipeline.pipe') + + +def test_resolve_raises_when_ref_content_is_not_a_pipe(monkeypatch): + _install_fake_git( + monkeypatch, + { + 'rev-parse': _completed(0, stdout='/repo\n'), + 'show': _completed(0, stdout=json.dumps({'version': 1})), + }, + ) + with pytest.raises(PipeDiffError, match="missing a 'components' list"): + resolve_git_ref('HEAD', '/repo/pipeline.pipe') + + +# --------------------------------------------------------------------------- +# Real git integration (isolated temp repo) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(shutil.which('git') is None, reason='git not installed') +def test_resolve_git_ref_real_repository(tmp_path): + def git(*args): + result = subprocess.run(['git', *args], cwd=tmp_path, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return result + + git('init', '-q') + pipe_file = tmp_path / 'pipeline.pipe' + pipe_file.write_text(json.dumps(SAMPLE_PIPE), encoding='utf-8') + git('add', 'pipeline.pipe') + git('-c', 'user.email=t@example.com', '-c', 'user.name=Tester', 'commit', '-q', '-m', 'init') + + # The committed content is returned from HEAD. + assert resolve_git_ref('HEAD', str(pipe_file)) == SAMPLE_PIPE + + # Working-tree edits do not affect what HEAD reports. + pipe_file.write_text(json.dumps({**SAMPLE_PIPE, 'version': 2}), encoding='utf-8') + assert resolve_git_ref('HEAD', str(pipe_file))['version'] == 1 + + # A file never committed is absent from HEAD -> None (treated as all-added). + untracked = tmp_path / 'untracked.pipe' + untracked.write_text(json.dumps(SAMPLE_PIPE), encoding='utf-8') + assert resolve_git_ref('HEAD', str(untracked)) is None + + +if __name__ == '__main__': + raise SystemExit(pytest.main([__file__, '-v'])) diff --git a/packages/client-python/tests/test_pipediff_reporters.py b/packages/client-python/tests/test_pipediff_reporters.py new file mode 100644 index 000000000..2ea2be211 --- /dev/null +++ b/packages/client-python/tests/test_pipediff_reporters.py @@ -0,0 +1,367 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Tests for rocketride.pipediff.reporters. + +The reporters are read-only over the diff model and are duck-typed, so these +tests build fixtures from lightweight dataclasses that mirror the pinned shapes +of ``rocketride.pipediff.model`` (owner: Implementer A). The real model +dataclasses are structurally identical; exercising the reporters against these +mirrors runs the true render code without depending on the engine being present. + +Coverage: + - empty diff, layout-only diff, and a mixed diff for each renderer + - human color on/off (ANSI escape presence) + - JSON document shape and summary contents + - Markdown table escaping of ``|`` and backticks + - deterministic ordering independent of engine emission order +""" + +import json +import unittest +from dataclasses import dataclass, field +from typing import Any, List, Optional, Tuple + +from rocketride.pipediff.reporters import render_human, render_json, render_markdown + + +# ========================================================================= +# Fixtures mirroring the pinned rocketride.pipediff.model dataclass shapes. +# ========================================================================= + + +@dataclass +class FieldChange: + """Mirror of model.FieldChange.""" + + path: str + kind: str # 'added' | 'removed' | 'changed' + old: Any = None + new: Any = None + + +@dataclass +class NodeChange: + """Mirror of model.NodeChange.""" + + id: str + kind: str # 'added' | 'removed' | 'provider' | 'config' + provider_old: Optional[str] = None + provider_new: Optional[str] = None + field_changes: List[FieldChange] = field(default_factory=list) + + +@dataclass +class EdgeChange: + """Mirror of model.EdgeChange.""" + + from_id: str + lane: str + to_id: str + kind: str # 'added' | 'removed' + + +@dataclass +class PipeDiff: + """Mirror of model.PipeDiff, including has_semantic_changes.""" + + node_changes: List[NodeChange] = field(default_factory=list) + edge_changes: List[EdgeChange] = field(default_factory=list) + version_change: Optional[Tuple[Any, Any]] = None + layout_changed: bool = False + + @property + def has_semantic_changes(self) -> bool: + """Layout is non-semantic; version/nodes/edges are.""" + return bool(self.node_changes or self.edge_changes or self.version_change) + + +def _mixed_diff() -> PipeDiff: + """Build a representative diff touching every section.""" + return PipeDiff( + node_changes=[ + NodeChange(id='qdrant_3', kind='added', provider_new='qdrant'), + NodeChange(id='webhook_1', kind='removed', provider_old='webhook'), + NodeChange(id='chat_1', kind='provider', provider_old='chat', provider_new='chat_v2'), + NodeChange( + id='preprocessor_langchain_1', + kind='config', + field_changes=[ + FieldChange(path='config.default.strlen', kind='changed', old=512, new=1024), + FieldChange(path='config.parameters.top_p', kind='added', new=0.9), + FieldChange(path='config.mode', kind='removed', old='Source'), + ], + ), + ], + edge_changes=[ + EdgeChange(from_id='parse_1', lane='text', to_id='preprocessor_langchain_1', kind='added'), + EdgeChange(from_id='webhook_1', lane='tags', to_id='parse_1', kind='removed'), + ], + version_change=(3, 4), + layout_changed=False, + ) + + +class TestRenderHuman(unittest.TestCase): + def test_empty_diff_reports_no_changes(self) -> None: + self.assertEqual(render_human(PipeDiff(), use_color=False), 'No semantic changes.') + + def test_layout_only_is_not_no_changes(self) -> None: + out = render_human(PipeDiff(layout_changed=True), use_color=False) + self.assertNotEqual(out, 'No semantic changes.') + self.assertIn('Layout', out) + + def test_mixed_sections_present(self) -> None: + out = render_human(_mixed_diff(), use_color=False) + self.assertIn('Nodes', out) + self.assertIn('Edges', out) + self.assertIn('Config', out) + # Added / removed / provider-change / config markers. + self.assertIn('+ qdrant_3 (qdrant)', out) + self.assertIn('- webhook_1 (webhook)', out) + self.assertIn('chat_1 provider: chat -> chat_v2', out) + self.assertIn('+ parse_1 --text--> preprocessor_langchain_1', out) + self.assertIn('- webhook_1 --tags--> parse_1', out) + self.assertIn('~ config.default.strlen: 512 -> 1024', out) + self.assertIn('Version: 3 -> 4', out) + + def test_no_color_has_no_ansi(self) -> None: + out = render_human(_mixed_diff(), use_color=False) + self.assertNotIn('\033', out) + + def test_color_emits_ansi(self) -> None: + out = render_human(_mixed_diff(), use_color=True) + self.assertIn('\033[', out) + + def test_no_trailing_newline(self) -> None: + out = render_human(_mixed_diff(), use_color=False) + self.assertFalse(out.endswith('\n')) + + +class TestRenderJson(unittest.TestCase): + def test_empty_diff_shape(self) -> None: + doc = render_json(PipeDiff()) + self.assertEqual(set(doc.keys()), {'nodes', 'edges', 'summary'}) + self.assertEqual(set(doc['nodes'].keys()), {'added', 'removed', 'changed'}) + self.assertEqual(set(doc['edges'].keys()), {'added', 'removed'}) + self.assertEqual(doc['nodes']['added'], []) + self.assertEqual(doc['edges']['removed'], []) + self.assertFalse(doc['summary']['has_semantic_changes']) + self.assertIsNone(doc['summary']['version_change']) + self.assertFalse(doc['summary']['layout_changed']) + + def test_layout_only_summary(self) -> None: + doc = render_json(PipeDiff(layout_changed=True)) + self.assertTrue(doc['summary']['layout_changed']) + self.assertFalse(doc['summary']['has_semantic_changes']) + + def test_mixed_structure(self) -> None: + doc = render_json(_mixed_diff()) + self.assertEqual(doc['nodes']['added'], [{'id': 'qdrant_3', 'provider': 'qdrant'}]) + self.assertEqual(doc['nodes']['removed'], [{'id': 'webhook_1', 'provider': 'webhook'}]) + + # Two changed nodes: chat_1 (provider) and preprocessor (config). + changed_by_id = {entry['id']: entry for entry in doc['nodes']['changed']} + self.assertEqual(changed_by_id['chat_1']['provider_change'], {'old': 'chat', 'new': 'chat_v2'}) + self.assertEqual(changed_by_id['chat_1']['config_changes'], []) + + preprocessor = changed_by_id['preprocessor_langchain_1'] + self.assertIsNone(preprocessor['provider_change']) + # config_changes deterministically sorted by (path, kind). + paths = [fc['path'] for fc in preprocessor['config_changes']] + self.assertEqual(paths, sorted(paths)) + for fc in preprocessor['config_changes']: + self.assertEqual(set(fc.keys()), {'path', 'kind', 'old', 'new'}) + + self.assertEqual( + doc['edges']['added'], + [{'from': 'parse_1', 'lane': 'text', 'to': 'preprocessor_langchain_1'}], + ) + self.assertEqual( + doc['edges']['removed'], + [{'from': 'webhook_1', 'lane': 'tags', 'to': 'parse_1'}], + ) + + summary = doc['summary'] + self.assertEqual(summary['nodes_added'], 1) + self.assertEqual(summary['nodes_removed'], 1) + self.assertEqual(summary['nodes_changed'], 2) + self.assertEqual(summary['edges_added'], 1) + self.assertEqual(summary['edges_removed'], 1) + self.assertEqual(summary['config_changes'], 3) + self.assertEqual(summary['provider_changes'], 1) + self.assertEqual(summary['version_change'], [3, 4]) + self.assertTrue(summary['has_semantic_changes']) + + def test_provider_and_config_on_same_id_merge(self) -> None: + # Engine may emit provider + config as separate NodeChange records for the + # same id; they must collapse into a single 'changed' entry. + diff = PipeDiff( + node_changes=[ + NodeChange(id='n1', kind='provider', provider_old='a', provider_new='b'), + NodeChange( + id='n1', + kind='config', + field_changes=[FieldChange(path='config.x', kind='changed', old=1, new=2)], + ), + ] + ) + doc = render_json(diff) + self.assertEqual(len(doc['nodes']['changed']), 1) + entry = doc['nodes']['changed'][0] + self.assertEqual(entry['provider_change'], {'old': 'a', 'new': 'b'}) + self.assertEqual(len(entry['config_changes']), 1) + + def test_json_serializable(self) -> None: + # The document must round-trip through the json module unchanged. + doc = render_json(_mixed_diff()) + self.assertEqual(json.loads(json.dumps(doc)), doc) + + +class TestRenderMarkdown(unittest.TestCase): + def test_empty_diff_summary(self) -> None: + out = render_markdown(PipeDiff()) + self.assertIn('no semantic changes', out) + self.assertNotIn('**Nodes**', out) + self.assertNotIn('**Edges**', out) + + def test_title_heading(self) -> None: + out = render_markdown(PipeDiff(), title='pipeline.pipe') + self.assertTrue(out.startswith('## pipeline.pipe')) + + def test_mixed_sections(self) -> None: + out = render_markdown(_mixed_diff()) + self.assertIn('**Pipeline diff:**', out) + self.assertIn('**Nodes**', out) + self.assertIn('**Edges**', out) + self.assertIn('**Config**', out) + self.assertIn('| Node | Field | Change |', out) + self.assertIn('`qdrant_3`', out) + self.assertIn('**Version:**', out) + + def test_layout_only(self) -> None: + out = render_markdown(PipeDiff(layout_changed=True)) + self.assertIn('Layout', out) + + def test_newlines_in_untrusted_values_cannot_break_out(self) -> None: + # A .pipe file is untrusted PR content, and render_markdown output is + # dumped verbatim into an auto-posted sticky PR comment. A newline in a + # provider/lane/id/config value must not terminate its code span and let + # the trailing text render as real Markdown (headings, @-mentions, etc.). + payload = 'x\n\n## INJECTED HEADING\n\n@everyone please approve' + diff = PipeDiff( + node_changes=[ + NodeChange(id='n1', kind='added', provider_new=payload), + NodeChange( + id='n2\n\n### id-inject', + kind='config', + field_changes=[ + FieldChange(path='config.p\n\n### path-inject', kind='changed', old='a\r\nb', new=payload), + ], + ), + ], + edge_changes=[ + EdgeChange(from_id='a', lane='l\n\n### lane-inject', to_id='b', kind='added'), + ], + ) + out = render_markdown(diff, title='rag.pipe') + # The injected content must never break out of its code span onto its own + # line — that is what would make '## INJECTED HEADING' render as a real + # heading. Every injected marker must survive only inline inside a span, + # never at the start of a line, and no attacker-introduced blank line may + # appear before a heading. + self.assertNotIn('\n\n#', out) + for line in out.splitlines(): + self.assertFalse(line.lstrip().startswith('## INJECTED'), line) + self.assertFalse(line.lstrip().startswith('### '), line) + self.assertFalse(line.lstrip().startswith('@everyone'), line) + # Carriage returns are neutralized too (a lone \r can also break rows). + self.assertNotIn('\r', out) + + def test_table_escapes_pipe_and_backtick(self) -> None: + diff = PipeDiff( + node_changes=[ + NodeChange( + id='n1', + kind='config', + field_changes=[ + FieldChange(path='config.template', kind='changed', old='a|b', new='x`y'), + ], + ) + ] + ) + out = render_markdown(diff) + # The literal pipe from the value must be escaped inside the table cell. + self.assertIn('\\|', out) + self.assertNotIn('a|b', out) + # A backtick inside a value forces a longer code fence. + self.assertIn('``', out) + # Every table body row must have balanced, escaped columns (no stray bar). + table_rows = [ln for ln in out.splitlines() if ln.startswith('| ') and '---' not in ln] + for row in table_rows: + # Unescaped pipes only appear as the 4 column delimiters. + unescaped = row.replace('\\|', '') + self.assertEqual(unescaped.count('|'), 4, row) + + +class TestDeterminism(unittest.TestCase): + def _scrambled_diff(self) -> PipeDiff: + """Same logical changes as _mixed_diff but emitted in a different order.""" + return PipeDiff( + node_changes=[ + NodeChange( + id='preprocessor_langchain_1', + kind='config', + field_changes=[ + FieldChange(path='config.mode', kind='removed', old='Source'), + FieldChange(path='config.parameters.top_p', kind='added', new=0.9), + FieldChange(path='config.default.strlen', kind='changed', old=512, new=1024), + ], + ), + NodeChange(id='chat_1', kind='provider', provider_old='chat', provider_new='chat_v2'), + NodeChange(id='webhook_1', kind='removed', provider_old='webhook'), + NodeChange(id='qdrant_3', kind='added', provider_new='qdrant'), + ], + edge_changes=[ + EdgeChange(from_id='webhook_1', lane='tags', to_id='parse_1', kind='removed'), + EdgeChange(from_id='parse_1', lane='text', to_id='preprocessor_langchain_1', kind='added'), + ], + version_change=(3, 4), + layout_changed=False, + ) + + def test_human_is_order_independent(self) -> None: + self.assertEqual( + render_human(_mixed_diff(), use_color=False), + render_human(self._scrambled_diff(), use_color=False), + ) + + def test_json_is_order_independent(self) -> None: + self.assertEqual(render_json(_mixed_diff()), render_json(self._scrambled_diff())) + + def test_markdown_is_order_independent(self) -> None: + self.assertEqual(render_markdown(_mixed_diff()), render_markdown(self._scrambled_diff())) + + +if __name__ == '__main__': + unittest.main() From c82433d2e083b786ed1b533e6fac67eba3c68f9a Mon Sep 17 00:00:00 2001 From: nihalnihalani <24360630+nihalnihalani@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:35:36 +0530 Subject: [PATCH 2/4] feat(cli): add 'rocketride diff' subcommand (#1606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rocketride diff | --git [--include-layout] [--json] [--markdown] [--exit-zero] Semantic pipeline diff for terminal and CI review. Unlike the other subcommands it takes no connection args and never touches the engine or network — it is pure local file/JSON/git work. Human output groups Nodes/Edges/Config with +/-/~ markers (NO_COLOR and non-tty aware); --json emits a single change document; --markdown emits PR-comment-safe output (untrusted .pipe values are escaped so a config value can't break out of a comment). Exit codes: 0 no semantic changes, 1 changes present, 2 usage/parse/git error; --exit-zero forces 0 for non-gating use. 30 tests (reporters + CLI) driving the full command against parsed fixtures — no server required. Co-Authored-By: Claude Fable 5 --- .../src/rocketride/cli/commands/__init__.py | 3 + .../src/rocketride/cli/commands/diff.py | 263 ++++++++++ .../client-python/src/rocketride/cli/main.py | 60 +++ packages/client-python/tests/test_diff_cli.py | 449 ++++++++++++++++++ 4 files changed, 775 insertions(+) create mode 100644 packages/client-python/src/rocketride/cli/commands/diff.py create mode 100644 packages/client-python/tests/test_diff_cli.py diff --git a/packages/client-python/src/rocketride/cli/commands/__init__.py b/packages/client-python/src/rocketride/cli/commands/__init__.py index 7edf47528..a663143b9 100644 --- a/packages/client-python/src/rocketride/cli/commands/__init__.py +++ b/packages/client-python/src/rocketride/cli/commands/__init__.py @@ -34,6 +34,7 @@ EventsCommand: Monitor real-time pipeline events ListCommand: List all active tasks StoreCommand: Project and template storage operations + DiffCommand: Semantic diff of two local .pipe pipeline files (no server) """ from .start import StartCommand @@ -43,6 +44,7 @@ from .events import EventsCommand from .list import ListCommand from .store import StoreCommand +from .diff import DiffCommand __all__ = [ 'StartCommand', @@ -52,4 +54,5 @@ 'EventsCommand', 'ListCommand', 'StoreCommand', + 'DiffCommand', ] diff --git a/packages/client-python/src/rocketride/cli/commands/diff.py b/packages/client-python/src/rocketride/cli/commands/diff.py new file mode 100644 index 000000000..2337a071f --- /dev/null +++ b/packages/client-python/src/rocketride/cli/commands/diff.py @@ -0,0 +1,263 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +RocketRide CLI Semantic Pipeline Diff Command Implementation. + +This module provides the DiffCommand class for the ``rocketride diff`` command, +which produces a *semantic* diff of two ``.pipe`` pipeline files. Raw JSON diffs +of ``.pipe`` files are dominated by canvas coordinate churn (the per-component +``ui`` block and the top-level ``viewport``); this command hides that noise and +surfaces what actually changed: nodes added/removed, provider changes, config +field changes, and edge (wiring) additions/removals. + +Unlike every other RocketRide subcommand, ``diff`` is a purely local operation. +It reads files (or a git ref) and compares parsed JSON entirely on the client; +it never connects to the engine or the network. Consequently it takes none of +the ``--uri``/``--apikey``/``--token`` connection arguments the other commands +share -- this is a deliberate and documented difference. + +Usage: + rocketride diff + rocketride diff --git + +Flags: + --include-layout Include layout churn (component ``ui`` blocks and the + top-level ``viewport``) that is ignored by default. Version + changes are always reported regardless of this flag. + --json Emit a single JSON document to stdout. + --markdown Emit compact, PR-comment-friendly Markdown to stdout. + --exit-zero Force exit code 0 on success, even when changes are present + (useful for non-gating, informational runs). + +Exit codes: + 0 No semantic changes (or --exit-zero on any successful run). + 1 Semantic changes were found. + 2 Usage error, or an unreadable/unparseable file / bad git ref. + +Components: + DiffCommand: Main command implementation for semantic ``.pipe`` diffing +""" + +import json +import os +import sys +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from .base import BaseCommand + +# The pipediff engine is the semantic core of this command. These are the pinned +# public names from the rocketride.pipediff package (Implementer A's modules). +from ...pipediff import ( + PipeDiffError, + diff_pipes, + load_pipe, + resolve_git_ref, +) +from ...pipediff.reporters import render_human, render_json, render_markdown + +if TYPE_CHECKING: + from ..main import RocketRideClient + + +# An empty pipeline used as the "old" side when --git names a ref in which the +# file does not yet exist: everything in the working-tree file is then reported +# as newly added. +_EMPTY_PIPE: Dict[str, Any] = {'components': []} + + +def _should_use_color(stream: Any = None) -> bool: + """ + Decide whether ANSI color should be used for human-readable output. + + Color is suppressed when the ``NO_COLOR`` environment variable is present + (per the informal https://no-color.org convention, regardless of its value) + or when the target stream is not an interactive terminal. + + Args: + stream: Stream that will receive the output (defaults to ``sys.stdout``). + + Returns: + True when color escapes are appropriate, False otherwise. + """ + if stream is None: + stream = sys.stdout + if 'NO_COLOR' in os.environ: + return False + isatty = getattr(stream, 'isatty', None) + return bool(isatty()) if callable(isatty) else False + + +class DiffCommand(BaseCommand): + """ + Command implementation for the local, semantic ``.pipe`` diff. + + Load two pipeline files (or one working-tree file against a git ref), compute + a semantic diff that ignores canvas layout noise, and render the result as + colored text, JSON, or Markdown. This command performs no network I/O and + requires no authentication. + + Example: + ```python + command = DiffCommand(cli, args) + exit_code = await command.execute() + ``` + + Key Features: + - Two-file and ``--git `` comparison modes + - Layout-noise suppression with an opt-in ``--include-layout`` override + - Human / JSON / Markdown reporters, selected by flag + - Standardized exit codes (0 unchanged, 1 changed, 2 error) + - Pure stdout for report output; all errors are written to stderr + """ + + def __init__(self, cli, args): + """ + Initialize DiffCommand with CLI context and parsed arguments. + + Args: + cli: CLI instance (used only for shared plumbing; no connection is + established by this command). + args: Parsed command line arguments (paths, --git, --include-layout, + --json, --markdown, --exit-zero). + """ + super().__init__(cli, args) + + def _fail(self, message: str) -> int: + """ + Report a usage/processing error on stderr and return exit code 2. + + Errors are always written to stderr so that ``--json`` and ``--markdown`` + output on stdout stays pure and machine-parseable. + + Args: + message: Human-readable error description. + + Returns: + The integer exit code ``2``. + """ + print(f'Error: {message}', file=sys.stderr) + return 2 + + def _resolve_inputs(self) -> Optional[tuple]: + """ + Validate arguments and load the (old, new) pipeline objects. + + Returns: + A ``(old, new)`` tuple of parsed pipeline dicts on success, or + ``None`` when validation or loading failed (an error has already been + printed to stderr by the caller path via raised exceptions). + + Raises: + PipeDiffError: Propagated from load_pipe / resolve_git_ref for + unreadable, unparseable, or structurally invalid inputs, or for a + bad git ref. The caller converts these into exit code 2. + ValueError: For argument-usage problems (wrong number of paths for the + selected mode). The caller converts these into exit code 2. + """ + paths: List[str] = list(getattr(self.args, 'paths', None) or []) + git_ref: Optional[str] = getattr(self.args, 'git', None) + + if git_ref: + if len(paths) != 1: + raise ValueError('--git requires exactly one FILE to compare against the ref') + file_path = paths[0] + new_obj = load_pipe(file_path) + old_obj = resolve_git_ref(git_ref, file_path) + if old_obj is None: + # File absent in the ref: treat everything as newly added. + old_obj = _EMPTY_PIPE + return old_obj, new_obj + + if len(paths) != 2: + raise ValueError('exactly two files are required: rocketride diff ') + + old_obj = load_pipe(paths[0]) + new_obj = load_pipe(paths[1]) + return old_obj, new_obj + + def _render(self, diff: Any) -> str: + """ + Render the diff using the reporter selected by the command flags. + + ``--json`` takes precedence over ``--markdown`` when both are somehow set; + argparse normally makes them mutually exclusive. With no format flag the + colored human report is produced (color auto-detected from stdout). + + Args: + diff: The PipeDiff produced by the engine. + + Returns: + The fully rendered report string for printing to stdout. + """ + if getattr(self.args, 'json', False): + return json.dumps(render_json(diff), indent=2, ensure_ascii=False, sort_keys=True) + if getattr(self.args, 'markdown', False): + return render_markdown(diff) + return render_human(diff, use_color=_should_use_color(sys.stdout)) + + async def execute(self, client: 'RocketRideClient' = None) -> int: + """ + Execute the semantic pipe diff and return the appropriate exit code. + + This command is fully local: the ``client`` argument is accepted only to + match the common command interface and is never used. + + Args: + client: Unused. Present for signature compatibility with other + commands dispatched by the CLI. + + Returns: + Exit code per the command contract: + - 0 when there are no semantic changes, or when ``--exit-zero`` + was passed and the run otherwise succeeded. + - 1 when semantic changes were found. + - 2 on a usage error or an unreadable/unparseable input. + + Process Flow: + 1. Validate arguments and load the old/new pipeline objects. + 2. Compute the semantic diff (respecting --include-layout). + 3. Render with the selected reporter and print to stdout. + 4. Map the diff outcome to an exit code (honoring --exit-zero). + """ + include_layout = bool(getattr(self.args, 'include_layout', False)) + + try: + resolved = self._resolve_inputs() + except ValueError as exc: + return self._fail(str(exc)) + except PipeDiffError as exc: + return self._fail(str(exc)) + + old_obj, new_obj = resolved + + try: + diff = diff_pipes(old_obj, new_obj, include_layout=include_layout) + except PipeDiffError as exc: + return self._fail(str(exc)) + + # Report output goes to stdout only; nothing above this point wrote there. + print(self._render(diff)) + + if getattr(self.args, 'exit_zero', False): + return 0 + return 1 if diff.has_semantic_changes else 0 diff --git a/packages/client-python/src/rocketride/cli/main.py b/packages/client-python/src/rocketride/cli/main.py index daff72ed6..35866cde2 100644 --- a/packages/client-python/src/rocketride/cli/main.py +++ b/packages/client-python/src/rocketride/cli/main.py @@ -67,6 +67,7 @@ from .commands.events import EventsCommand from .commands.list import ListCommand from .commands.store import StoreCommand +from .commands.diff import DiffCommand try: # Try importing from installed package first @@ -508,6 +509,58 @@ def add_common_args(subparser): ) stat_parser.add_argument('path', help='File or directory path') + # Diff command - semantic diff of two .pipe pipeline files. + # + # This command is intentionally *local only*: it never contacts the engine + # or the network, so it does NOT take the shared --uri/--apikey/--token + # connection arguments (add_common_args) that every other command uses. + diff_parser = subparsers.add_parser( + 'diff', + help='Semantic diff of two .pipe pipeline files (local; no server)', + description=( + 'Compare two RocketRide .pipe pipeline files semantically, surfacing node, ' + 'edge, and config changes while ignoring canvas layout noise (the per-node ' + '"ui" block and top-level "viewport"). This command runs entirely locally ' + 'and never connects to the engine or network, so it takes no ' + '--uri/--apikey/--token arguments.' + ), + ) + diff_parser.add_argument( + 'paths', + nargs='*', + metavar='FILE', + help='Two pipe files to compare (old new), or a single FILE when using --git', + ) + diff_parser.add_argument( + '--git', + metavar='REF', + help='Diff the working-tree FILE against this git ref (via "git show REF:FILE")', + ) + diff_parser.add_argument( + '--include-layout', + action='store_true', + help='Include layout churn (per-node "ui" blocks and top-level "viewport") ignored by default', + ) + + # --json and --markdown select mutually exclusive output formats. + diff_format_group = diff_parser.add_mutually_exclusive_group() + diff_format_group.add_argument( + '--json', + action='store_true', + help='Emit the diff as a single JSON document to stdout', + ) + diff_format_group.add_argument( + '--markdown', + action='store_true', + help='Emit the diff as compact, PR-comment-friendly Markdown to stdout', + ) + + diff_parser.add_argument( + '--exit-zero', + action='store_true', + help='Always exit 0 on a successful run, even when changes are found (non-gating)', + ) + return parser async def run(self) -> int: @@ -537,6 +590,13 @@ async def run(self) -> int: parser.print_help() return 1 + # Diff command is fully local: no server connection, no auth, no client. + # Dispatch it here, before any connection setup, since it does not carry + # the shared --uri/--apikey/--token arguments the other commands rely on. + if self.args.command == 'diff': + self.command = DiffCommand(self, self.args) + return await self.command.execute() + # Validate we have something for apikey if not self.args.apikey: self.args.apikey = '' diff --git a/packages/client-python/tests/test_diff_cli.py b/packages/client-python/tests/test_diff_cli.py new file mode 100644 index 000000000..5b7f58496 --- /dev/null +++ b/packages/client-python/tests/test_diff_cli.py @@ -0,0 +1,449 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Tests for the ``rocketride diff`` CLI subcommand (rocketride.cli.commands.diff). + +These exercise ``DiffCommand.execute`` end-to-end against real ``.pipe`` files on +disk, driving it through the same pinned engine and reporters the CLI uses in +production. The command is fully local (no server, no auth), so no client fixture +or network mock is needed; the only external dependency stubbed here is +``resolve_git_ref`` for ``--git`` mode, patched in the command's own module +namespace the same way the engine test suite stubs subprocess. + +Coverage mirrors the product contract: + - two-file happy path (semantic changes -> exit 1) + - identical files (-> exit 0, "No semantic changes.") + - ``--git`` mode with a mocked resolver (found ref, and file-absent -> all added) + - unreadable / unparseable / non-pipe input (-> exit 2, error on stderr) + - argument-usage errors (wrong file count for the mode -> exit 2) + - ``--json`` output purity (stdout is a single parseable document) + - ``--markdown`` output + - ``--include-layout`` toggling ui.* field lines and the exit code + - ``--exit-zero`` forcing 0 on changes but never masking an error + - argparse registration: 'diff' exists, takes no connection args, and makes + ``--json``/``--markdown`` mutually exclusive +""" + +import json +from types import SimpleNamespace + +import pytest + +from rocketride.cli.commands import diff as diff_module +from rocketride.cli.commands.diff import DiffCommand +from rocketride.cli.main import RocketRideCLI + + +# ========================================================================= +# Helpers / fixtures +# ========================================================================= + + +class _StubCLI: + """Minimal stand-in for the CLI context. + + ``DiffCommand`` stores the CLI on construction but never touches it during + ``execute`` (the command is fully local), so an attribute-free stub is enough + to satisfy ``BaseCommand.__init__``. + """ + + +def _diff_args( + paths=None, + *, + git=None, + include_layout=False, + json=False, # noqa: A002 - mirrors the argparse dest name exactly + markdown=False, + exit_zero=False, +): + """Build a parsed-args namespace shaped like the diff subparser's output.""" + return SimpleNamespace( + paths=list(paths or []), + git=git, + include_layout=include_layout, + json=json, + markdown=markdown, + exit_zero=exit_zero, + ) + + +def _write_pipe(tmp_path, name, obj): + """Write ``obj`` as JSON to ``name`` under ``tmp_path`` and return the path str.""" + path = tmp_path / name + path.write_text(json.dumps(obj), encoding='utf-8') + return str(path) + + +def _old_pipe(): + """A small but complete pipeline: webhook -> chat, with layout and version.""" + return { + 'version': 1, + 'viewport': {'x': 0, 'y': 0, 'zoom': 1}, + 'components': [ + { + 'id': 'a', + 'provider': 'webhook', + 'config': {'mode': 'Source'}, + 'ui': {'position': {'x': 0, 'y': 0}}, + }, + { + 'id': 'b', + 'provider': 'chat', + 'config': {'default': {'strlen': 512}}, + 'input': [{'lane': 'text', 'from': 'a'}], + 'ui': {'position': {'x': 10, 'y': 10}}, + }, + ], + } + + +def _new_pipe(): + """``_old_pipe`` with a semantic delta: node 'c' added, edge b->c, b.strlen changed.""" + pipe = _old_pipe() + pipe['components'][1]['config']['default']['strlen'] = 1024 + pipe['components'].append( + { + 'id': 'c', + 'provider': 'qdrant', + 'config': {}, + 'input': [{'lane': 'vec', 'from': 'b'}], + 'ui': {'position': {'x': 20, 'y': 20}}, + } + ) + return pipe + + +def _layout_only_new_pipe(): + """``_old_pipe`` with *only* canvas movement: node 'a' relocated, nothing else.""" + pipe = _old_pipe() + pipe['components'][0]['ui']['position'] = {'x': 400, 'y': 250} + return pipe + + +async def _run(args): + """Instantiate and execute a DiffCommand, returning its integer exit code.""" + command = DiffCommand(_StubCLI(), args) + return await command.execute() + + +# ========================================================================= +# Two-file mode +# ========================================================================= + + +@pytest.mark.asyncio +async def test_two_file_changes_exit_1(tmp_path, capsys): + old = _write_pipe(tmp_path, 'old.pipe', _old_pipe()) + new = _write_pipe(tmp_path, 'new.pipe', _new_pipe()) + + code = await _run(_diff_args([old, new])) + + out = capsys.readouterr().out + assert code == 1 + assert 'Nodes' in out + assert '+ c (qdrant)' in out + assert '+ b --vec--> c' in out + assert 'config.default.strlen' in out + + +@pytest.mark.asyncio +async def test_identical_files_exit_0(tmp_path, capsys): + same = _write_pipe(tmp_path, 'same.pipe', _old_pipe()) + + code = await _run(_diff_args([same, same])) + + captured = capsys.readouterr() + assert code == 0 + assert captured.out.strip() == 'No semantic changes.' + assert captured.err == '' + + +# ========================================================================= +# --git mode (resolver mocked) +# ========================================================================= + + +@pytest.mark.asyncio +async def test_git_mode_diffs_against_ref(tmp_path, capsys, monkeypatch): + new = _write_pipe(tmp_path, 'pipeline.pipe', _new_pipe()) + # Resolver returns the *old* pipe as it existed at the ref. + monkeypatch.setattr(diff_module, 'resolve_git_ref', lambda ref, path: _old_pipe()) + + code = await _run(_diff_args([new], git='HEAD')) + + out = capsys.readouterr().out + assert code == 1 + assert '+ c (qdrant)' in out + + +@pytest.mark.asyncio +async def test_git_mode_absent_file_is_all_added(tmp_path, capsys, monkeypatch): + new = _write_pipe(tmp_path, 'pipeline.pipe', _new_pipe()) + # None => file did not exist at the ref; everything is newly added. + monkeypatch.setattr(diff_module, 'resolve_git_ref', lambda ref, path: None) + + code = await _run(_diff_args([new], git='HEAD', json=True)) + + doc = json.loads(capsys.readouterr().out) + assert code == 1 + added_ids = {n['id'] for n in doc['nodes']['added']} + assert added_ids == {'a', 'b', 'c'} + assert doc['nodes']['removed'] == [] + + +@pytest.mark.asyncio +async def test_git_mode_requires_exactly_one_file(tmp_path, capsys): + a = _write_pipe(tmp_path, 'a.pipe', _old_pipe()) + b = _write_pipe(tmp_path, 'b.pipe', _new_pipe()) + + code = await _run(_diff_args([a, b], git='HEAD')) + + captured = capsys.readouterr() + assert code == 2 + assert captured.out == '' + assert '--git requires exactly one FILE' in captured.err + + +@pytest.mark.asyncio +async def test_git_resolver_error_exit_2(tmp_path, capsys, monkeypatch): + new = _write_pipe(tmp_path, 'pipeline.pipe', _new_pipe()) + + def _boom(ref, path): + raise diff_module.PipeDiffError('git show HEAD:pipeline.pipe failed: bad ref') + + monkeypatch.setattr(diff_module, 'resolve_git_ref', _boom) + + code = await _run(_diff_args([new], git='HEAD')) + + captured = capsys.readouterr() + assert code == 2 + assert captured.out == '' + assert 'Error:' in captured.err + assert 'bad ref' in captured.err + + +# ========================================================================= +# Error / usage handling (exit 2) +# ========================================================================= + + +@pytest.mark.asyncio +async def test_missing_file_exit_2(tmp_path, capsys): + existing = _write_pipe(tmp_path, 'old.pipe', _old_pipe()) + missing = str(tmp_path / 'does_not_exist.pipe') + + code = await _run(_diff_args([existing, missing])) + + captured = capsys.readouterr() + assert code == 2 + assert captured.out == '' + assert 'Error:' in captured.err + + +@pytest.mark.asyncio +async def test_invalid_json_exit_2(tmp_path, capsys): + old = _write_pipe(tmp_path, 'old.pipe', _old_pipe()) + bad = tmp_path / 'bad.pipe' + bad.write_text('{ this is not json', encoding='utf-8') + + code = await _run(_diff_args([old, str(bad)])) + + captured = capsys.readouterr() + assert code == 2 + assert captured.out == '' + assert 'Error:' in captured.err + + +@pytest.mark.asyncio +async def test_non_pipe_object_exit_2(tmp_path, capsys): + old = _write_pipe(tmp_path, 'old.pipe', _old_pipe()) + # Valid JSON object, but no 'components' list -> not a pipeline. + notpipe = _write_pipe(tmp_path, 'notpipe.pipe', {'hello': 'world'}) + + code = await _run(_diff_args([old, notpipe])) + + captured = capsys.readouterr() + assert code == 2 + assert captured.out == '' + assert 'Error:' in captured.err + + +@pytest.mark.asyncio +async def test_wrong_positional_count_exit_2(tmp_path, capsys): + only = _write_pipe(tmp_path, 'only.pipe', _old_pipe()) + + code = await _run(_diff_args([only])) # one file, no --git + + captured = capsys.readouterr() + assert code == 2 + assert captured.out == '' + assert 'two files are required' in captured.err + + +@pytest.mark.asyncio +async def test_no_files_exit_2(capsys): + code = await _run(_diff_args([])) + + captured = capsys.readouterr() + assert code == 2 + assert captured.out == '' + assert 'two files are required' in captured.err + + +# ========================================================================= +# Output-format flags +# ========================================================================= + + +@pytest.mark.asyncio +async def test_json_output_is_pure_and_parseable(tmp_path, capsys): + old = _write_pipe(tmp_path, 'old.pipe', _old_pipe()) + new = _write_pipe(tmp_path, 'new.pipe', _new_pipe()) + + code = await _run(_diff_args([old, new], json=True)) + + captured = capsys.readouterr() + assert code == 1 + # stdout must be exactly one JSON document, nothing else. + doc = json.loads(captured.out) + assert set(doc.keys()) == {'nodes', 'edges', 'summary'} + assert doc['summary']['has_semantic_changes'] is True + assert captured.err == '' + + +@pytest.mark.asyncio +async def test_markdown_output(tmp_path, capsys): + old = _write_pipe(tmp_path, 'old.pipe', _old_pipe()) + new = _write_pipe(tmp_path, 'new.pipe', _new_pipe()) + + code = await _run(_diff_args([old, new], markdown=True)) + + out = capsys.readouterr().out + assert code == 1 + assert '**Pipeline diff:**' in out + assert '**Nodes**' in out + assert '| Node | Field | Change |' in out + + +# ========================================================================= +# --include-layout +# ========================================================================= + + +@pytest.mark.asyncio +async def test_layout_only_hidden_by_default_exit_0(tmp_path, capsys): + old = _write_pipe(tmp_path, 'old.pipe', _old_pipe()) + new = _write_pipe(tmp_path, 'new.pipe', _layout_only_new_pipe()) + + code = await _run(_diff_args([old, new])) + + out = capsys.readouterr().out + # Pure canvas movement is non-semantic: exit 0, coarse layout hint, no ui.* lines. + assert code == 0 + assert 'Layout' in out + assert 'ui.position' not in out + + +@pytest.mark.asyncio +async def test_include_layout_surfaces_ui_fields_exit_1(tmp_path, capsys): + old = _write_pipe(tmp_path, 'old.pipe', _old_pipe()) + new = _write_pipe(tmp_path, 'new.pipe', _layout_only_new_pipe()) + + code = await _run(_diff_args([old, new], include_layout=True)) + + out = capsys.readouterr().out + # Opting layout in enumerates ui.* field changes and makes them count. + assert code == 1 + assert 'ui.position' in out + + +# ========================================================================= +# --exit-zero +# ========================================================================= + + +@pytest.mark.asyncio +async def test_exit_zero_forces_success_on_changes(tmp_path, capsys): + old = _write_pipe(tmp_path, 'old.pipe', _old_pipe()) + new = _write_pipe(tmp_path, 'new.pipe', _new_pipe()) + + code = await _run(_diff_args([old, new], exit_zero=True)) + + out = capsys.readouterr().out + # Changes are still reported, but the exit code is forced to 0 for non-gating use. + assert code == 0 + assert '+ c (qdrant)' in out + + +@pytest.mark.asyncio +async def test_exit_zero_does_not_mask_errors(tmp_path, capsys): + old = _write_pipe(tmp_path, 'old.pipe', _old_pipe()) + missing = str(tmp_path / 'nope.pipe') + + code = await _run(_diff_args([old, missing], exit_zero=True)) + + captured = capsys.readouterr() + # --exit-zero only forces 0 on a *successful* run; a load error is still exit 2. + assert code == 2 + assert captured.out == '' + assert 'Error:' in captured.err + + +# ========================================================================= +# argparse registration (parser-level, no execution) +# ========================================================================= + + +def test_parser_registers_diff_subcommand(): + parser = RocketRideCLI().setup_parser() + ns = parser.parse_args(['diff', 'old.pipe', 'new.pipe']) + assert ns.command == 'diff' + assert ns.paths == ['old.pipe', 'new.pipe'] + assert ns.git is None + assert ns.include_layout is False + assert ns.json is False + assert ns.markdown is False + assert ns.exit_zero is False + + +def test_parser_diff_takes_no_connection_args(): + parser = RocketRideCLI().setup_parser() + ns = parser.parse_args(['diff', 'old.pipe', 'new.pipe']) + # This command never touches the engine, so the shared connection args that + # every other subcommand carries must be absent from its namespace. + assert not hasattr(ns, 'apikey') + assert not hasattr(ns, 'uri') + assert not hasattr(ns, 'token') + + +def test_parser_json_and_markdown_are_mutually_exclusive(): + parser = RocketRideCLI().setup_parser() + with pytest.raises(SystemExit): + parser.parse_args(['diff', '--json', '--markdown', 'old.pipe', 'new.pipe']) + + +if __name__ == '__main__': + import sys + + sys.exit(pytest.main([__file__, '-v'])) From f479689714df39e4c07361b7c199da97ae6de49e Mon Sep 17 00:00:00 2001 From: nihalnihalani <24360630+nihalnihalani@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:35:48 +0530 Subject: [PATCH 3/4] =?UTF-8?q?feat(ci):=20add=20pipe-diff=20composite=20a?= =?UTF-8?q?ction=20=E2=80=94=20semantic=20.pipe=20diff=20as=20a=20sticky?= =?UTF-8?q?=20PR=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On PRs touching .pipe files, renders each changed file's semantic diff (vs the PR base) and posts/updates one sticky comment — turning 'review pipelines like code' into visible review UX. Installs the rocketride CLI (fails fast if the release lacks 'diff'; cli-version to pin), fetches the PR base commit so it works with actions/checkout default fetch-depth 1, and runs 'rocketride diff --git --markdown'. Third-party actions pinned by commit SHA; GITHUB_TOKEN never echoed; needs pull-requests: write (documented). No existing workflow files touched. Co-Authored-By: Claude Fable 5 --- .github/actions/pipe-diff/README.md | 128 +++++++++++ .github/actions/pipe-diff/action.yml | 330 +++++++++++++++++++++++++++ 2 files changed, 458 insertions(+) create mode 100644 .github/actions/pipe-diff/README.md create mode 100644 .github/actions/pipe-diff/action.yml diff --git a/.github/actions/pipe-diff/README.md b/.github/actions/pipe-diff/README.md new file mode 100644 index 000000000..edb45a6f3 --- /dev/null +++ b/.github/actions/pipe-diff/README.md @@ -0,0 +1,128 @@ +# RocketRide Pipe Diff action + +A composite GitHub Action that posts a **semantic** diff of changed `.pipe` +pipeline files as a single, sticky pull-request comment. + +Raw JSON diffs of `.pipe` files are dominated by canvas coordinate churn — the +per-node `ui` block and the top-level `viewport` — which buries the changes that +actually matter. This action wraps the local [`rocketride diff`](../../../packages/client-python) +CLI subcommand to surface only what changed: nodes added/removed, provider +changes, config field changes, and edge (wiring) additions/removals. + +The action runs **entirely on the runner**. Like the `rocketride diff` command it +wraps, it never contacts the RocketRide engine or the network — it only reads +files and git objects and compares parsed JSON. It therefore needs no +`--uri`/`--apikey`/`--token` credentials of any kind. + +## Usage + +Add a workflow that checks out the repo and runs this action on pull requests: + +```yaml +name: Pipe diff +on: + pull_request: + +# Reading the repo, and writing the sticky comment. +permissions: + contents: read + pull-requests: write + +jobs: + pipe-diff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: ./.github/actions/pipe-diff +``` + +`actions/checkout` with its default `fetch-depth: 1` is enough — the action +fetches the PR base commit itself before diffing. + +### With inputs + +```yaml + - uses: ./.github/actions/pipe-diff + with: + files: 'pipelines/**/*.pipe' # restrict which .pipe files are considered + python-version: '3.12' + cli-version: '==1.4.0' # pin a specific published rocketride release + comment: 'true' # set 'false' to skip commenting + include-layout: 'false' # set 'true' to also report ui/viewport churn +``` + +## Inputs + +| Input | Default | Description | +| ---------------- | ------------- | ----------- | +| `files` | `**/*.pipe` | Git pathspec glob selecting the `.pipe` files to consider. Git's `:(glob)` magic is applied automatically, so `**` matches across directories. Only files that actually changed versus the PR base are diffed. | +| `python-version` | `3.12` | Python version passed to `actions/setup-python`. RocketRide requires Python >= 3.10. | +| `cli-version` | `` (empty) | pip version specifier appended to the `rocketride` requirement, e.g. `==1.4.0` or `>=1.4,<2`. Empty installs the latest published release. The run **fails fast** with a clear message if the installed CLI lacks the `diff` subcommand. | +| `comment` | `true` | When `true`, post or update one sticky PR comment. Set to `false` to compute the diff (job log + outputs) without commenting; that mode needs no `pull-requests: write` permission. | +| `include-layout` | `false` | When `true`, treat canvas layout churn (per-node `ui` blocks and the top-level `viewport`) as meaningful and enumerate it. When `false` (default) a pure-layout change is reported as `no semantic changes (layout only)`. | + +## Outputs + +| Output | Description | +| ---------------------- | ----------- | +| `changed-files` | Number of changed `.pipe` files considered (added or modified vs the PR base). | +| `has-semantic-changes` | `true` when at least one changed `.pipe` file has semantic changes, else `false`. | +| `comment-body-file` | Path to the generated Markdown comment body (useful for debugging or reuse). | + +## Permissions + +The sticky-comment step needs `pull-requests: write` (comments are issue +comments on the PR). `contents: read` is required to check out and diff the +repository. If you set `comment: false`, only `contents: read` is needed. + +The action authenticates the GitHub REST calls with the workflow's built-in +`GITHUB_TOKEN`; it never prints the token. + +## Behavior + +- **Sticky comment.** The action maintains exactly one comment per PR, found and + updated via a hidden HTML marker (``). Re-runs + update that comment in place instead of stacking new ones. +- **Layout noise is hidden by default.** A change that only moves nodes on the + canvas produces exit code 0 from the CLI and is summarized as + `No semantic changes (layout only).`. Pass `include-layout: true` to enumerate + the `ui`/`viewport` deltas instead. +- **Version changes are always reported** (they are semantic-ish), regardless of + `include-layout`. +- **New files.** A `.pipe` file added in the PR (absent in the base) is diffed + against an empty pipeline, so every node and edge shows up as added. +- **Deleted files.** Removed `.pipe` files are noted in the comment (there is no + working-tree file to diff). +- **Failure semantics.** The action does **not** fail merely because semantic + changes exist — it is informational. It **does** fail (and emits an `error` + annotation) when a changed `.pipe` file cannot be parsed or diffed, so a broken + pipeline in a PR is surfaced as a red check. + +## Local equivalent + +Everything the action does per file is one local CLI call — no server, no token: + +```bash +# Diff a working-tree .pipe file against the PR base commit, as Markdown: +rocketride diff --git "$(git merge-base origin/main HEAD)" pipelines/my.pipe --markdown + +# Or diff two files directly: +rocketride diff old.pipe new.pipe + +# Add --include-layout to surface ui/viewport churn; --json for machine output. +``` + +Exit codes match the action's per-file logic: `0` = no semantic changes, +`1` = semantic changes, `2` = usage error / unreadable / unparseable file. + +## Pinned dependencies + +Third-party actions are pinned by full commit SHA (with a version comment), +matching the convention in [`.github/workflows`](../../workflows): + +- `actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065` (v5) +- `actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea` (v7) + +## License + +MIT — see the repository [`LICENSE`](../../../LICENSE). diff --git a/.github/actions/pipe-diff/action.yml b/.github/actions/pipe-diff/action.yml new file mode 100644 index 000000000..76404b972 --- /dev/null +++ b/.github/actions/pipe-diff/action.yml @@ -0,0 +1,330 @@ +# MIT License +# +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# Composite action that posts a semantic diff of changed .pipe pipeline files as +# a sticky pull-request comment. It wraps the local `rocketride diff` subcommand +# (Python SDK/CLI), which compares parsed .pipe JSON entirely on the runner and +# never contacts the RocketRide engine or the network. Raw JSON diffs of .pipe +# files are dominated by canvas coordinate churn (the per-node "ui" block and the +# top-level "viewport"); this surfaces what actually changed — nodes, edges, and +# config — so reviewers see signal instead of layout noise. +name: RocketRide Pipe Diff +description: >- + Comment a semantic diff of changed .pipe pipeline files on a pull request, + hiding canvas-layout noise. Runs entirely on the runner; never contacts the + RocketRide engine or the network. +author: RocketRide, Inc. + +# Branding for the GitHub Marketplace / action listing. +branding: + icon: git-pull-request + color: purple + +inputs: + files: + description: >- + Git pathspec glob (":(glob)" magic is applied automatically) selecting the + .pipe files to consider. The action diffs only those that actually changed + versus the PR base. + required: false + default: '**/*.pipe' + python-version: + description: Python version passed to actions/setup-python (RocketRide requires >= 3.10). + required: false + default: '3.12' + cli-version: + description: >- + pip version specifier appended to the "rocketride" requirement, e.g. + "==1.4.0" or ">=1.4,<2". Leave empty to install the latest published + release. The run fails fast with a clear message if the installed CLI does + not provide the "diff" subcommand. + required: false + default: '' + comment: + description: >- + When "true" (default), post or update ONE sticky PR comment with the diff. + Set to "false" to compute the diff and write a job summary without + commenting (requires no pull-requests:write permission). + required: false + default: 'true' + include-layout: + description: >- + When "true", treat canvas layout churn (per-node "ui" blocks and the + top-level "viewport") as meaningful and enumerate it. Default "false" hides + it and reports a pure-layout change as "no semantic changes (layout only)". + required: false + default: 'false' + +outputs: + changed-files: + description: Number of changed .pipe files considered (added or modified vs the PR base). + value: ${{ steps.diff.outputs.changed-files }} + has-semantic-changes: + description: '"true" when at least one changed .pipe file has semantic changes, else "false".' + value: ${{ steps.diff.outputs.has-semantic-changes }} + comment-body-file: + description: Path to the generated Markdown comment body (useful for debugging or reuse). + value: ${{ steps.diff.outputs.comment-body-file }} + +runs: + using: composite + steps: + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install RocketRide CLI + shell: bash + env: + CLI_VERSION: ${{ inputs.cli-version }} + run: | + set -euo pipefail + # Build the pip requirement. CLI_VERSION, when set, is a PEP 508 version + # specifier appended to the distribution name (e.g. "==1.4.0", ">=1.4"). + if [ -n "${CLI_VERSION}" ]; then + requirement="rocketride${CLI_VERSION}" + else + requirement="rocketride" + fi + echo "Installing ${requirement}" + python -m pip install --disable-pip-version-check --quiet "${requirement}" + + - name: Verify the CLI provides 'diff' + shell: bash + run: | + set -euo pipefail + if ! command -v rocketride >/dev/null 2>&1; then + echo "::error::'rocketride' CLI is not on PATH after installation." + exit 1 + fi + # `rocketride diff --help` exits 0 only when the subcommand exists; + # argparse exits non-zero ("invalid choice: 'diff'") on older releases. + if ! rocketride diff --help >/dev/null 2>&1; then + installed="$(python -c 'import importlib.metadata as m; print(m.version("rocketride"))' 2>/dev/null || echo unknown)" + echo "::error::The installed 'rocketride' CLI (version ${installed}) does not provide the 'diff' subcommand. Pin a release that includes it via the action's 'cli-version' input once 'rocketride diff' is published." + exit 1 + fi + echo "rocketride diff is available." + + - name: Compute semantic pipe diffs + id: diff + shell: bash + env: + FILES_GLOB: ${{ inputs.files }} + INCLUDE_LAYOUT: ${{ inputs.include-layout }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + # Backticks in the single-quoted printf formats below are LITERAL + # Markdown code-span characters, not command substitutions; single + # quotes are required to keep them literal (SC2016 is a false positive). + # shellcheck disable=SC2016 + set -euo pipefail + + marker='' + body_file="${RUNNER_TEMP:-/tmp}/rocketride-pipe-diff-comment.md" + : > "${body_file}" + + # Emit step outputs in one place so every early return stays consistent. + emit_outputs() { + { + echo "changed-files=${1}" + echo "has-semantic-changes=${2}" + echo "pipe-files-changed=${3}" + echo "errored=${4}" + echo "comment-body-file=${body_file}" + } >> "${GITHUB_OUTPUT}" + } + + # A pull_request event is required: the base commit is what we diff + # against. Outside that context there is nothing to compare, so skip. + if [ -z "${BASE_SHA}" ]; then + echo "::warning::No pull_request base SHA in context; the pipe-diff action only runs on pull_request events. Skipping." + printf '%s\n' "${marker}" > "${body_file}" + emit_outputs 0 false false false + exit 0 + fi + + # actions/checkout uses fetch-depth 1 by default, so the base commit is + # usually absent. Fetch just that object. GitHub allows fetching an + # arbitrary reachable SHA directly. + if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + echo "Fetching PR base commit ${BASE_SHA}" + git fetch --no-tags --depth=1 origin "${BASE_SHA}" 2>/dev/null \ + || git fetch --no-tags origin "${BASE_SHA}" 2>/dev/null \ + || true + fi + if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + echo "::error::Could not fetch the PR base commit ${BASE_SHA}. Ensure actions/checkout ran before this action and left credentials available (the default)." + exit 1 + fi + + # Collect changed .pipe files (added or modified; deletions handled + # separately). NUL-delimited to survive unusual path characters. + changed_files=() + while IFS= read -r -d '' path; do + changed_files+=("${path}") + done < <(git diff -z --name-only --diff-filter=d "${BASE_SHA}" HEAD -- ":(glob)${FILES_GLOB}") + + # Deleted .pipe files: reported as a note (no working-tree file to diff). + deleted_files=() + while IFS= read -r -d '' path; do + deleted_files+=("${path}") + done < <(git diff -z --name-only --diff-filter=D "${BASE_SHA}" HEAD -- ":(glob)${FILES_GLOB}") + + changed_count="${#changed_files[@]}" + deleted_count="${#deleted_files[@]}" + + # Nothing to report: emit an empty (marker-only) body. The comment step + # will refresh an existing sticky comment but will NOT create a new one. + if [ "${changed_count}" -eq 0 ] && [ "${deleted_count}" -eq 0 ]; then + echo "No changed .pipe files versus the base." + { + printf '%s\n' "${marker}" + printf '## RocketRide pipeline diff\n\n' + printf 'No `.pipe` files changed in this pull request.\n' + } > "${body_file}" + emit_outputs 0 false false false + exit 0 + fi + + include_layout_flag="" + if [ "${INCLUDE_LAYOUT}" = "true" ]; then + include_layout_flag="--include-layout" + fi + + sections_file="${RUNNER_TEMP:-/tmp}/rocketride-pipe-diff-sections.md" + : > "${sections_file}" + + any_semantic=false + any_error=false + err_file="${RUNNER_TEMP:-/tmp}/rocketride-pipe-diff-stderr.txt" + + # The "${arr[@]+...}" guard expands to nothing for an empty array, so the + # loop is a no-op under `set -u` on every bash version (a bare + # "${arr[@]}" of an empty array errors on bash < 4.4). + for f in ${changed_files[@]+"${changed_files[@]}"}; do + printf '### `%s`\n\n' "${f}" >> "${sections_file}" + + # Build the command as an always-non-empty array so "${cmd[@]}" is + # safe under `set -u` on every bash version (an empty-array expansion + # errors on bash < 4.4). + cmd=(rocketride diff --git "${BASE_SHA}" "${f}" --markdown) + if [ -n "${include_layout_flag}" ]; then + cmd+=("${include_layout_flag}") + fi + + set +e + out="$("${cmd[@]}" 2>"${err_file}")" + rc=$? + set -e + + case "${rc}" in + 0) + # Exit 0 = no semantic changes. Any textual change the git diff + # picked up was pure layout/formatting noise. + printf 'No semantic changes (layout only).\n\n' >> "${sections_file}" + ;; + 1) + any_semantic=true + printf '%s\n\n' "${out}" >> "${sections_file}" + ;; + *) + any_error=true + err_msg="$(cat "${err_file}")" + echo "::error file=${f}::rocketride diff failed for ${f}: ${err_msg}" + { + printf 'Could not diff this file (exit %s):\n\n' "${rc}" + printf '```\n%s\n```\n\n' "${err_msg}" + } >> "${sections_file}" + ;; + esac + done + + for f in ${deleted_files[@]+"${deleted_files[@]}"}; do + printf '### `%s`\n\n' "${f}" >> "${sections_file}" + printf 'Pipeline file removed in this pull request.\n\n' >> "${sections_file}" + done + + # Top summary line. + if [ "${any_error}" = "true" ]; then + summary='One or more pipeline files could not be diffed. See details below.' + elif [ "${any_semantic}" = "true" ]; then + summary='Semantic pipeline changes detected. See details below.' + else + summary='No semantic changes (layout only).' + fi + + { + printf '%s\n' "${marker}" + printf '## RocketRide pipeline diff\n\n' + printf '%s\n\n' "${summary}" + cat "${sections_file}" + printf -- '---\n' + printf 'Semantic diff of `.pipe` files versus the PR base. Canvas layout (per-node `ui` and the top-level `viewport`) is ignored by default. Generated by the RocketRide pipe-diff action.\n' + } > "${body_file}" + + emit_outputs "${changed_count}" "${any_semantic}" true "${any_error}" + + - name: Post or update sticky PR comment + if: ${{ inputs.comment == 'true' && github.event_name == 'pull_request' }} + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + env: + BODY_FILE: ${{ steps.diff.outputs.comment-body-file }} + PIPE_FILES_CHANGED: ${{ steps.diff.outputs.pipe-files-changed }} + with: + script: | + const fs = require('fs'); + const marker = ''; + const body = fs.readFileSync(process.env.BODY_FILE, 'utf8'); + const pipeFilesChanged = process.env.PIPE_FILES_CHANGED === 'true'; + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + if (!issue_number) { + core.info('No pull-request number in context; skipping comment.'); + return; + } + + // Find our one sticky comment by its hidden marker. + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number, per_page: 100, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + core.info(`Updated sticky pipe-diff comment (${existing.id}).`); + } else if (pipeFilesChanged) { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + core.info('Created sticky pipe-diff comment.'); + } else { + core.info('No .pipe changes and no existing comment; nothing to post.'); + } + + - name: Fail if any pipe file could not be diffed + if: ${{ steps.diff.outputs.errored == 'true' }} + shell: bash + run: | + set -euo pipefail + echo "::error::One or more changed .pipe files could not be parsed or diffed. See the annotations and the PR comment above." + exit 1 From b217a2a5d804c8c7340f94b1814c8e143edf0425 Mon Sep 17 00:00:00 2001 From: nihalnihalani <24360630+nihalnihalani@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:35:48 +0530 Subject: [PATCH 4/4] docs: semantic diff guide, CLI reference, and raw-vs-semantic example Documents the diff subcommand (flags, --git mode, three output modes, exit codes) and adds examples/pipe-diff-example.md showing a raw JSON diff full of x/y churn next to the semantic output for the same change. Ties validate + eval + diff into the 'pipelines reviewed like code in CI' story. Co-Authored-By: Claude Fable 5 --- docs/README-python-client.md | 94 +++++++++- examples/pipe-diff-example.md | 259 +++++++++++++++++++++++++++ packages/docs/content-static/cli.mdx | 210 +++++++++++++++++++++- 3 files changed, 557 insertions(+), 6 deletions(-) create mode 100644 examples/pipe-diff-example.md diff --git a/docs/README-python-client.md b/docs/README-python-client.md index 37f79bf7a..04f7f6f8b 100644 --- a/docs/README-python-client.md +++ b/docs/README-python-client.md @@ -555,9 +555,101 @@ rocketride stop --token # Terminate a running task rocketride list # List all active tasks rocketride events ALL --token # Stream task events rocketride rrext_store get_all_projects # List stored projects +rocketride diff old.pipe new.pipe # Semantic diff of two .pipe files (local; no server) ``` -All commands accept `--uri` and `--apikey` flags, or read from environment variables. +All commands **except `diff`** accept `--uri` and `--apikey` flags, or read from environment variables. `diff` runs entirely locally and takes no connection flags — see below. + +### Semantic pipeline diff (`rocketride diff`) + +A raw `git diff` of a `.pipe` file is dominated by **canvas coordinate churn**: every node has a `ui` block whose `position.x`/`position.y` change whenever you nudge it, plus a top-level `viewport` that shifts when you pan or zoom. Those lines say nothing about behavior, yet they bury the changes that do — a swapped LLM provider, a re-tuned chunk size, a rewired retrieval step. + +`rocketride diff` computes a **semantic** diff instead. It matches components by `id` and reports what changed across three axes — **Nodes** (added / removed / re-provisioned), **Edges** (the wiring between components, from each node's `input[]` data lanes and `control[]` agent-orchestration lanes), and **Config** (per-field changes as readable dotted paths like `config.default.strlen`) — while collapsing all layout churn into a single line. + +> **Local only — no engine, no auth.** Unlike every other subcommand, `diff` never connects to an engine or the network. It reads files (or a git ref) and compares parsed JSON on your machine, so it takes **none** of the `--uri` / `--apikey` / `--token` arguments the other commands share. + +```bash +# Compare two files (old, then new) +rocketride diff old.pipe new.pipe + +# Compare a working-tree file against a git ref (uses `git show :`) +rocketride diff --git HEAD rag.pipe +rocketride diff --git main rag.pipe +``` + +| Flag | Description | +| --- | --- | +| ` ` | The two files to compare (positional). Omit one and pass `--git` instead. | +| `--git ` | Diff the working-tree `FILE` against `` via `git show :`. If the file is absent in ``, everything is reported as added. | +| `--include-layout` | Enumerate the layout churn (each node's `ui` block and the `viewport`) that is hidden by default, as `ui.*` field changes. | +| `--json` | Emit a single JSON document to stdout (mutually exclusive with `--markdown`). | +| `--markdown` | Emit compact, PR-comment-friendly Markdown to stdout (mutually exclusive with `--json`). | +| `--exit-zero` | Always exit `0` on a successful run, even when changes are found (non-gating). | + +**Exit codes** make it easy to gate a CI job: + +| Code | Meaning | +| --- | --- | +| `0` | No semantic changes (or any successful run when `--exit-zero` is set). | +| `1` | Semantic changes were found. | +| `2` | Usage error, or an unreadable / unparseable file or bad git ref. | + +Layout (the `ui` blocks and `viewport`) is ignored by default — a pure canvas move exits `0`. Version changes (the top-level `version` field) are always reported and always count as a change. + +**Three output modes.** The default is grouped, colored text (color auto-disables when piped or when `NO_COLOR` is set), with `+` added, `-` removed, `~` changed: + +```text +Pipeline diff: 1 node changed, layout changed + +Config + chunker_1 + ~ config.default.strlen: 512 -> 1024 + +Layout: changed (ui/viewport) +``` + +`--json` emits one stable, sorted document with `nodes`, `edges`, and a `summary` block: + +```json +{ + "edges": { "added": [], "removed": [] }, + "nodes": { + "added": [], + "changed": [ + { + "config_changes": [ + { "kind": "changed", "new": 1024, "old": 512, "path": "config.default.strlen" } + ], + "id": "chunker_1", + "provider_change": null + } + ], + "removed": [] + }, + "summary": { + "config_changes": 1, "edges_added": 0, "edges_removed": 0, + "has_semantic_changes": true, "layout_changed": true, + "nodes_added": 0, "nodes_changed": 1, "nodes_removed": 0, + "provider_changes": 0, "version_change": null + } +} +``` + +`--markdown` emits a compact PR-comment-friendly report (`|` and backticks safely escaped) suitable for posting from a GitHub Action: + +```markdown +**Pipeline diff:** 1 node changed, layout changed + +**Config** + +| Node | Field | Change | +| --- | --- | --- | +| `chunker_1` | `config.default.strlen` | `512` → `1024` | + +_Layout (ui/viewport) changed._ +``` + +**Review pipelines like code in CI.** Because `diff` is local and exits non-zero on change, it drops straight into a pull-request check: a GitHub Actions job runs `rocketride diff --git origin/ --markdown` and posts the summary as a PR comment. This is the review half of the "pipelines as code" loop — **validate** a `.pipe` to confirm it is well-formed before it runs (via [`client.validate()`](#services-validation-and-ping)), **evaluate** it to measure output quality, and `diff` it to see exactly what changed between revisions. See the [side-by-side example and PR-comment recipe](https://github.com/rocketride-org/rocketride-server/blob/develop/examples/pipe-diff-example.md). ## Configuration diff --git a/examples/pipe-diff-example.md b/examples/pipe-diff-example.md new file mode 100644 index 000000000..df249e5c2 --- /dev/null +++ b/examples/pipe-diff-example.md @@ -0,0 +1,259 @@ +# Semantic pipeline diff: raw JSON vs. `rocketride diff` + +A `.pipe` file is JSON, so `git diff` "works" on it — but the output is mostly +noise. Every component carries a `ui` block whose `position.x` / `position.y` +change whenever you nudge a node on the canvas, and the top-level `viewport` +shifts when you pan or zoom. None of that changes what the pipeline *does*, yet it +dominates the diff and buries the changes that matter. + +`rocketride diff` is a **semantic** diff for `.pipe` files. It compares the parts +that affect behavior — nodes, wiring, and config — and collapses all canvas churn +into a single line. This page shows the exact same change through both lenses. + +## The change + +Someone upgraded a RAG pipeline in one commit: + +- **Swapped the LLM** from OpenAI to Anthropic (`llm_openai` → `llm_anthropic`, + with the matching profile/key config). +- **Tightened the prompt** — added a "cite your sources" instruction. +- **Inserted a reranker** (`reranker_cohere`) between retrieval and the prompt, so + the `documents` lane now flows `qdrant_1 → reranker_1 → prompt_1` instead of + `qdrant_1 → prompt_1`. +- ...and, incidentally, **tidied the canvas** — nudged every node a few pixels and + zoomed out a little. + +Three real changes. One cosmetic one. Here is what each tool shows. + +## What a raw `git diff` shows + +Of the **69** added/removed lines in the raw diff, **33 are pure canvas +coordinates** — nearly half the diff is noise, and it's interleaved with the real +changes so a reviewer has to hunt for them. Excerpt (repetitive `x`/`y` hunks +elided): + +```diff +@@ -11,8 +11,8 @@ + }, + "ui": { + "position": { +- "x": 20, +- "y": 500 ++ "x": 32, ++ "y": 512 + }, + "measured": { + +@@ ... 5 more identical x/y position hunks, one per node ... @@ + +@@ -80,18 +80,45 @@ + } + }, + { ++ "id": "reranker_1", ++ "provider": "reranker_cohere", ++ "config": { ++ "profile": "rerank-3", ++ "parameters": {}, ++ "topN": 5 ++ }, ++ "input": [ ++ { "lane": "documents", "from": "qdrant_1" } ++ ], ++ "ui": { ++ "position": { "x": 690, "y": 380 }, ++ "measured": { "width": 150, "height": 66 }, ++ "nodeType": "default", ++ "formDataValid": true ++ } ++ }, ++ { + "id": "prompt_1", + "provider": "prompt", + "config": { + "instructions": [ +- "Answer using only the retrieved context." ++ "Answer using only the retrieved context. Cite the source document for every claim." + ], + "parameters": {} + }, + "input": [ + { + "lane": "documents", +- "from": "qdrant_1" ++ "from": "reranker_1" + }, + +@@ -113,11 +140,11 @@ + { + "id": "llm_1", +- "provider": "llm_openai", ++ "provider": "llm_anthropic", + "config": { +- "profile": "openai-4o", +- "openai-4o": { +- "apikey": "${ROCKETRIDE_OPENAI_KEY}" ++ "profile": "claude", ++ "claude": { ++ "apikey": "${ROCKETRIDE_ANTHROPIC_KEY}" + }, + "parameters": {} + }, + +@@ ... 3 more x/y position hunks ... @@ + +@@ -168,9 +195,9 @@ + "project_id": "1327e7c0-8479-4ab7-a319-c4dc944daeb5", + "viewport": { +- "x": 0, +- "y": 0, +- "zoom": 1 ++ "x": -120, ++ "y": -40, ++ "zoom": 0.85 + }, + "version": 1 + } +``` + +To review this, you scroll past six coordinate hunks and a viewport change to find +the three lines that actually matter — and it's on you to notice that the +`prompt_1` input silently switched from `qdrant_1` to `reranker_1`. + +## What `rocketride diff` shows + +```bash +rocketride diff old.pipe new.pipe +``` + +```text +Pipeline diff: 1 node added, 2 nodes changed, 2 edges added, 1 edge removed, layout changed + +Nodes + + reranker_1 (reranker_cohere) + ~ llm_1 provider: llm_openai -> llm_anthropic + +Edges + + qdrant_1 --documents--> reranker_1 + + reranker_1 --documents--> prompt_1 + - qdrant_1 --documents--> prompt_1 + +Config + llm_1 + + config.claude = {"apikey": "${ROCKETRIDE_ANTHROPIC_KEY}"} + - config.openai-4o = {"apikey": "${ROCKETRIDE_OPENAI_KEY}"} + ~ config.profile: openai-4o -> claude + prompt_1 + ~ config.instructions[0]: Answer using only the retrieved context. -> Answer using only the retrieved context. Cite the source document for every claim. + +Layout: changed (ui/viewport) +``` + +Every real change is called out — including the rewire, shown explicitly as a +removed edge (`qdrant_1 --documents--> prompt_1`) plus two added edges through the +new reranker — and the entire canvas cleanup is one honest `Layout: changed` line. +The command exits `1` because there are semantic changes; a pure canvas move would +exit `0`. (Pass `--include-layout` if you *do* want the individual `ui.*` +coordinates enumerated.) + +## For a PR comment: `--markdown` + +`--markdown` renders the same diff as a compact, comment-safe report — the form +the CI job below posts on the pull request: + +```markdown +## Pipeline changes in rag.pipe + +**Pipeline diff:** 1 node added, 2 nodes changed, 2 edges added, 1 edge removed, layout changed + +**Nodes** +- + `reranker_1` (`reranker_cohere`) +- ~ `llm_1` provider: `llm_openai` → `llm_anthropic` + +**Edges** +- + `qdrant_1` --`documents`--> `reranker_1` +- + `reranker_1` --`documents`--> `prompt_1` +- - `qdrant_1` --`documents`--> `prompt_1` + +**Config** + +| Node | Field | Change | +| --- | --- | --- | +| `llm_1` | `config.claude` | + `{'apikey': '${ROCKETRIDE_ANTHROPIC_KEY}'}` | +| `llm_1` | `config.openai-4o` | - `{'apikey': '${ROCKETRIDE_OPENAI_KEY}'}` | +| `llm_1` | `config.profile` | `openai-4o` → `claude` | +| `prompt_1` | `config.instructions[0]` | `Answer using only the retrieved context.` → `Answer using only the retrieved context. Cite the source document for every claim.` | + +_Layout (ui/viewport) changed._ +``` + +There's also `--json`, a single stable, sorted document (`nodes`, `edges`, and a +`summary` block) for feeding other tooling. See the +[CLI reference](../docs/README-python-client.md#semantic-pipeline-diff-rocketride-diff) +for every flag and exit code. + +## Use it in CI (the pipe-diff GitHub Action) + +Because `diff` is local, network-free, and exits non-zero on change, it drops +straight into a pull-request check. The supported integration is the bundled +[`pipe-diff` composite action](../.github/actions/pipe-diff/), which finds **every** +changed `.pipe`, diffs each against the PR base, and posts one **sticky PR comment** +so reviewers see the semantic change instead of the coordinate noise: + +```yaml +# .github/workflows/pipe-diff.yml +name: Pipeline diff +on: pull_request +permissions: + contents: read + pull-requests: write +jobs: + diff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 # default fetch-depth: 1 is fine; the action fetches the base itself + - uses: ./.github/actions/pipe-diff +``` + +The action installs the CLI, resolves the PR base commit (fetching it under the +default shallow checkout), and updates a single comment in place on re-runs. Its +[README](../.github/actions/pipe-diff/README.md) documents the `files`, +`cli-version`, `comment`, and `include-layout` inputs. + +If you would rather not vendor the composite action, the same result can be +assembled inline for a single file — check out with `fetch-depth: 0` so the base +branch is present for `--git`, then diff and post the Markdown yourself: + +```yaml +# Alternative: inline equivalent of the composite action, for a single file. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need the base branch for `git show` + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install rocketride + - name: Semantic diff of the pipeline + run: | + rocketride diff --git "origin/${{ github.base_ref }}" rag.pipe \ + --markdown --exit-zero | tee pipe-diff.md + - name: Comment on the PR + uses: marocchino/sticky-pull-request-comment@v2 + with: + path: pipe-diff.md +``` + +`--exit-zero` keeps the comment step from being skipped when changes exist. Drop +`--exit-zero` (and the sticky-comment step) if you'd rather the job **fail** the +check whenever a pipeline changes and require an explicit review. + +## Why this matters + +This is the review half of treating **pipelines as code**: **validate** a `.pipe` +to confirm it is well-formed before it ever runs, **evaluate** it to measure output +quality, and **diff** it to see exactly what changed between revisions — the same +lint / test / review loop teams already run on their source, now applied to their +AI pipelines. A reviewer approving "OpenAI → Anthropic, added a reranker, tightened +the prompt" is reviewing the pipeline; a reviewer squinting at `"x": 900` → `"x": +1120` is reviewing the canvas. `rocketride diff` makes sure they only ever have to +do the first. diff --git a/packages/docs/content-static/cli.mdx b/packages/docs/content-static/cli.mdx index 2d5d24efc..04f8fc6f3 100644 --- a/packages/docs/content-static/cli.mdx +++ b/packages/docs/content-static/cli.mdx @@ -14,9 +14,9 @@ installing either package puts `rocketride` on your path. > **Python vs TypeScript CLI:** Both packages ship a `rocketride` command. The > core commands (`start`, `upload`, `status`, `stop`, `store`) are available in -> both, but flag names differ in a few places and the Python CLI includes two -> additional commands (`events`, `list`) not present in TypeScript. Differences -> are called out inline below. +> both, but flag names differ in a few places and the Python CLI includes three +> additional commands (`events`, `list`, `diff`) not present in TypeScript. +> Differences are called out inline below. ## Install @@ -117,8 +117,9 @@ rocketride stop --token ## Connecting -Every command accepts connection options, which also read from the environment -so you can set them once: +Every command _except_ [`diff`](#diff) — which runs entirely locally and never +contacts an engine — accepts connection options, which also read from the +environment so you can set them once: | Option | Env var | Default | Description | | ---------------- | ---------------------- | --------------------- | ---------------------------------------------------------------------- | @@ -141,6 +142,7 @@ connection is encrypted. | `events` | Stream all raw events from a running task. Python CLI only. | | `list` | List all active tasks. Python CLI only. | | `store` | File-store operations: `dir`, `type`, `write`, `rm`, `mkdir`, `stat`. | +| `diff` | Semantic diff of two `.pipe` files, ignoring canvas noise. Local; no server. Python CLI only. | ### start @@ -310,6 +312,204 @@ Sub-commands: | `mkdir ` | Create a directory in the store. | | `stat ` | Print metadata (size, type, modified time) for ``. | +### diff + +Use `diff` to see what actually changed between two versions of a `.pipe` file. +A raw `git diff` of a pipeline is dominated by **canvas coordinate churn** — every +time you nudge a node, its `ui.position` `x`/`y` values change, and the top-level +`viewport` shifts when you pan or zoom. Those lines say nothing about behavior, +yet they bury the changes that do: a swapped LLM provider, a re-tuned chunk size, +a rewired retrieval step. `diff` computes a **semantic** diff instead — it groups +changes into **Nodes** (added / removed / re-provisioned), **Edges** (the wiring +between components), and **Config** (per-field changes as readable dotted paths) — +and collapses all layout churn into a single line. + +> **Local only — no engine, no auth.** Unlike every other command, `diff` never +> connects to an engine or the network: it reads files (or a git ref) and compares +> parsed JSON entirely on your machine. It therefore takes **none** of the +> `--uri` / `--apikey` / `--token` connection options the other commands share. +> Available in the Python CLI only. + +```bash +# Compare two files on disk (old, then new) +rocketride diff old.pipe new.pipe + +# Compare a working-tree file against a git ref (uses `git show :`) +rocketride diff --git HEAD rag.pipe +rocketride diff --git main rag.pipe + +# Machine-readable output +rocketride diff old.pipe new.pipe --json +rocketride diff old.pipe new.pipe --markdown + +# Include the layout churn that is hidden by default +rocketride diff old.pipe new.pipe --include-layout +``` + +Key flags: + +| Flag | Description | +| --- | --- | +| ` ` | The two files to compare (positional). Omit one and pass `--git` instead. | +| `--git ` | Diff the single working-tree `FILE` against `` via `git show :`. If the file does not exist in ``, everything is reported as added. | +| `--include-layout` | Enumerate the layout churn (each node's `ui` block and the top-level `viewport`) that is hidden by default, as `ui.*` field changes. | +| `--json` | Emit a single JSON document to stdout (mutually exclusive with `--markdown`). | +| `--markdown` | Emit compact, PR-comment-friendly Markdown to stdout (mutually exclusive with `--json`). | +| `--exit-zero` | Always exit `0` on a successful run, even when changes are found. Use for informational, non-gating runs. | + +**Exit codes** make `diff` easy to gate a CI job on: + +| Code | Meaning | +| --- | --- | +| `0` | No semantic changes (or any successful run when `--exit-zero` is set). | +| `1` | Semantic changes were found. | +| `2` | Usage error, or an unreadable / unparseable file or bad git ref. | + +#### What counts as a change + +- **Nodes** are matched by `id`. A new or deleted `id` is an add/remove; an `id` + present on both sides with a different `provider` is a provider change. +- **Config** is deep-diffed into dotted paths — nested objects become + `config.default.strlen`, list items become `config.instructions[0]` — and each + leaf is reported as added, removed, or changed with its old → new value. +- **Edges** are the directed wires between components, reconstructed from every + component's `input[]` (data lanes) and `control[]` (agent orchestration lanes, + such as an agent's `llm`, `tool`, or `memory`). Rewiring a step shows up as a + removed edge plus an added edge. +- **Version** changes (the top-level `version` field) are always reported and + always count as a change — they are never hidden. +- **Layout** — each node's `ui` block and the `viewport` — is ignored by default + and summarized as a single `Layout: changed` line. A pure canvas move is **not** + a semantic change, so `diff` exits `0` on it. Pass `--include-layout` to see the + individual `ui.*` coordinates. + +#### Output modes + +The default **human** output is grouped and colored (color auto-disables when +piped or when `NO_COLOR` is set), with `+` added, `-` removed, and `~` changed: + +```text +Pipeline diff: 1 node changed, layout changed + +Config + chunker_1 + ~ config.default.strlen: 512 -> 1024 + +Layout: changed (ui/viewport) +``` + +`--json` emits one stable, sorted document — `nodes`, `edges`, and a `summary` +block with counts and the overall `has_semantic_changes` flag — ideal for feeding +another tool: + +```json +{ + "edges": { "added": [], "removed": [] }, + "nodes": { + "added": [], + "changed": [ + { + "config_changes": [ + { "kind": "changed", "new": 1024, "old": 512, "path": "config.default.strlen" } + ], + "id": "chunker_1", + "provider_change": null + } + ], + "removed": [] + }, + "summary": { + "config_changes": 1, + "edges_added": 0, + "edges_removed": 0, + "has_semantic_changes": true, + "layout_changed": true, + "nodes_added": 0, + "nodes_changed": 1, + "nodes_removed": 0, + "provider_changes": 0, + "version_change": null + } +} +``` + +`--markdown` emits a compact, PR-comment-friendly report — a one-line summary, +bullet lists for nodes and edges, and a table for config changes (with `|` and +backticks safely escaped so untrusted values can't break the comment): + +```markdown +**Pipeline diff:** 1 node changed, layout changed + +**Config** + +| Node | Field | Change | +| --- | --- | --- | +| `chunker_1` | `config.default.strlen` | `512` → `1024` | + +_Layout (ui/viewport) changed._ +``` + +#### Review pipelines like code in CI + +Because `diff` is local and exits non-zero on change, it drops straight into a +pull-request check. The supported way to wire it up is the bundled +[`pipe-diff` composite action](https://github.com/rocketride-org/rocketride-server/tree/develop/.github/actions/pipe-diff), +which finds every changed `.pipe`, diffs each against the PR base, and posts one +**sticky** comment so reviewers see "OpenAI → Anthropic, chunk size 512 → 1024, +retrieval rewired through a reranker" instead of a wall of coordinate noise: + +```yaml +# .github/workflows/pipe-diff.yml +name: Pipeline diff +on: pull_request +permissions: + contents: read + pull-requests: write +jobs: + diff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 # default fetch-depth: 1 is fine; the action fetches the base itself + - uses: ./.github/actions/pipe-diff +``` + +The action installs the CLI, resolves the PR base commit, and maintains a single +sticky comment for you. See its +[README](https://github.com/rocketride-org/rocketride-server/blob/develop/.github/actions/pipe-diff/README.md) +for the `files`, `cli-version`, `comment`, and `include-layout` inputs. + +If you would rather not vendor the composite action, the same result can be +assembled inline — check out with `fetch-depth: 0` so the base branch is present +for `--git`, then diff and post the Markdown yourself: + +```yaml +# Alternative: inline equivalent of the composite action, for a single file. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need the base branch for `git show` + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install rocketride + - name: Semantic diff of the pipeline + run: | + rocketride diff --git "origin/${{ github.base_ref }}" rag.pipe \ + --markdown --exit-zero | tee pipe-diff.md + - name: Comment on the PR + uses: marocchino/sticky-pull-request-comment@v2 + with: + path: pipe-diff.md +``` + +This is the review half of the "pipelines as code" story: **validate** a `.pipe` +to confirm it is well-formed before it runs (via +[`client.validate()`](/develop/python)), **[evaluate](/evaluate/use-cases)** it to +measure output quality, and `diff` it to review exactly what changed between +revisions — the same lint / test / review loop teams already run on their source, +now applied to their pipelines. See the +[side-by-side example](https://github.com/rocketride-org/rocketride-server/blob/develop/examples/pipe-diff-example.md) +for the full before/after contrast and the PR-comment recipe. + ## Related - [TypeScript SDK](/develop/typescript) · [Python SDK](/develop/python): the