From 76a33c867df458b864309f791e1a165aedeb0b7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:37:10 +0000 Subject: [PATCH 1/2] Initial plan From 820b64aa72dad0ec70fae5a6e89a386986a1610a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:46:22 +0000 Subject: [PATCH 2/2] feat: add duckflow-mcp MCP server with install script and agent instructions Co-authored-by: warnes <6144863+warnes@users.noreply.github.com> Agent-Logs-Url: https://github.com/Warnes-Innovations/duckflow/sessions/8111d8de-fc8a-4971-b559-b42d61b0e030 --- README.md | 61 +- duckflow/server.py | 207 +++++++ install.sh | 569 ++++++++++++++++++ pyproject.toml | 2 + templates/agent-setup/AGENTS.md | 44 ++ templates/agent-setup/CLAUDE.md | 44 ++ .../copilot/copilot-instructions.md | 45 ++ .../copilot/prompts/duckflow.prompt.md | 57 ++ .../copilot/skills/duckflow/SKILL.md | 68 +++ tests/test_mcp_server.py | 325 ++++++++++ 10 files changed, 1420 insertions(+), 2 deletions(-) create mode 100644 duckflow/server.py create mode 100755 install.sh create mode 100644 templates/agent-setup/AGENTS.md create mode 100644 templates/agent-setup/CLAUDE.md create mode 100644 templates/agent-setup/copilot/copilot-instructions.md create mode 100644 templates/agent-setup/copilot/prompts/duckflow.prompt.md create mode 100644 templates/agent-setup/copilot/skills/duckflow/SKILL.md create mode 100644 tests/test_mcp_server.py diff --git a/README.md b/README.md index 04404e6..7b472bb 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,13 @@ The split is intentional: - `duckflow/core.py`: extraction, filtering, stitching, and Mermaid rendering logic - `duckflow/cli.py`: installable CLI entry points +- `duckflow/server.py`: MCP server exposing duckflow tools via FastMCP - `scripts/extract_duckflow.py`: CLI for normalized JSON output - `scripts/generate_duckflow_mermaid.py`: CLI for Mermaid flowchart output - `tests/test_duckflow_tools.py`: parser and graph-behavior tests +- `tests/test_mcp_server.py`: MCP server tool tests +- `templates/agent-setup/`: agent instruction templates for Copilot, Codex, Claude Code, and Cline +- `install.sh`: interactive install script for all four AI tool clients - `docs/skill-outline.md`: draft user-level skill outline - `docs/prompt-structure.md`: draft `/duckflow` prompt structure @@ -81,7 +85,7 @@ The extractor and Mermaid generator build edges from local facts only: ```bash python -m venv .venv source .venv/bin/activate -pip install -e .[dev] +pip install -e ".[dev,mcp]" ``` ## Commands @@ -119,5 +123,58 @@ python scripts/generate_duckflow_mermaid.py --repo-root . --match summary Restrict scanning to explicit globs: ```bash -python scripts/extract_duckflow.py --repo-root . --include "src/**/*.py" --include "web/**/*.ts" +duckflow-extract --repo-root . --include "src/**/*.py" --include "web/**/*.ts" +``` + +## MCP server + +`duckflow-mcp` exposes duckflow tools to AI agents over the Model Context +Protocol using [FastMCP](https://github.com/jlowin/fastmcp). + +### Running the server directly + +```bash +duckflow-mcp +``` + +or with `uvx` from this checkout: + +```bash +uvx --from . duckflow-mcp +``` + +### Available tools + +| Tool | Description | +|------|-------------| +| `duckflow_extract` | Extract annotations from a repository path | +| `duckflow_extract_text` | Extract annotations from source text already in the conversation | +| `duckflow_stitch` | Build a stitched call/data-flow graph for a repository | +| `duckflow_mermaid` | Render a Mermaid flowchart for a repository | +| `duckflow_mermaid_text` | Render a Mermaid flowchart from source text | + +All tools accept an optional `match` argument to filter entries by substring. +`duckflow_extract` also accepts `stitched=true` to return a graph with nodes +and edges rather than a flat list. + +### Agent setup + +Run the interactive install script to wire `duckflow-mcp` into GitHub Copilot, +Codex, Claude Code, and/or Cline: + +```bash +bash install.sh +``` + +The script prompts you to choose which clients to configure and whether to +install instructions at user level or inside a target project directory. It +writes the MCP server entry into each client's config file and merges the +duckflow workflow instructions into the appropriate agent instruction file +(`copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`, or `.clinerules`). + +To install from the published GitHub URL instead of a local checkout, replace +the `--from` path in the generated config with: + +``` +git+https://github.com/warnes-innovations/duckflow ``` diff --git a/duckflow/server.py b/duckflow/server.py new file mode 100644 index 0000000..134f1f0 --- /dev/null +++ b/duckflow/server.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Gregory R. Warnes +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Duckflow MCP Server — tools for data-flow annotation extraction and rendering. + +Uses FastMCP for concise tool registration. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +from mcp.server.fastmcp import FastMCP + +from duckflow.core import ( + extract_duckflow_entries, + extract_duckflow_entries_from_text, + filter_entries, + render_mermaid, + stitch_duckflow, +) + +mcp = FastMCP( + "duckflow-mcp", + instructions=( + "Duckflow tools for extracting, stitching, and rendering " + "comment-based data-flow annotations." + ), +) + +_TOOL_EXCEPTIONS = (OSError, ValueError, json.JSONDecodeError) + + +# --------------------------------------------------------------------------- +# Tool: duckflow_extract +# --------------------------------------------------------------------------- + + +@mcp.tool() +def duckflow_extract( + repo_root: str, + include: Optional[list[str]] = None, + match: Optional[str] = None, + stitched: bool = False, +) -> str: + """Extract duckflow annotations from source files beneath repo_root. + + Args: + repo_root: Absolute path to the repository root to scan. + include: Optional list of glob patterns to restrict which source files + are scanned (e.g. ["src/**/*.py", "web/**/*.ts"]). + Defaults to all supported source-file extensions. + match: Optional substring filter applied across entry ids, kinds, + statuses, paths, notes, and tokens. Only matching entries + are returned. + stitched: When True, return a stitched graph object with "nodes" and + "edges" keys instead of a flat list of entries. + """ + try: + root = Path(repo_root) + if not root.exists(): + return f"ERROR: repo_root does not exist: {repo_root}" + entries = extract_duckflow_entries(root, include=include) + entries = filter_entries(entries, match) + if stitched: + return json.dumps(stitch_duckflow(entries), indent=2) + return json.dumps([e.to_dict() for e in entries], indent=2) + except _TOOL_EXCEPTIONS as exc: + return f"ERROR: {exc}" + + +# --------------------------------------------------------------------------- +# Tool: duckflow_extract_text +# --------------------------------------------------------------------------- + + +@mcp.tool() +def duckflow_extract_text( + text: str, + path: str = "", + match: Optional[str] = None, +) -> str: + """Extract duckflow annotations directly from a string of source text. + + Useful when the source code is already loaded in the conversation rather + than residing on disk. + + Args: + text: Source text to scan for duckflow annotations. + path: Logical file path to use in error messages and entry metadata + (default: ""). + match: Optional substring filter. + """ + try: + entries = extract_duckflow_entries_from_text(text, Path(path)) + entries = filter_entries(entries, match) + return json.dumps([e.to_dict() for e in entries], indent=2) + except _TOOL_EXCEPTIONS as exc: + return f"ERROR: {exc}" + + +# --------------------------------------------------------------------------- +# Tool: duckflow_stitch +# --------------------------------------------------------------------------- + + +@mcp.tool() +def duckflow_stitch( + repo_root: str, + include: Optional[list[str]] = None, + match: Optional[str] = None, +) -> str: + """Build a stitched duckflow graph from all annotations in repo_root. + + The graph contains "nodes" (all matching entries) and "edges" stitched + from call / data relationships between them. + + Args: + repo_root: Absolute path to the repository root to scan. + include: Optional list of glob patterns (see duckflow_extract). + match: Optional substring filter applied before stitching. + """ + try: + root = Path(repo_root) + if not root.exists(): + return f"ERROR: repo_root does not exist: {repo_root}" + entries = extract_duckflow_entries(root, include=include) + entries = filter_entries(entries, match) + return json.dumps(stitch_duckflow(entries), indent=2) + except _TOOL_EXCEPTIONS as exc: + return f"ERROR: {exc}" + + +# --------------------------------------------------------------------------- +# Tool: duckflow_mermaid +# --------------------------------------------------------------------------- + + +@mcp.tool() +def duckflow_mermaid( + repo_root: str, + include: Optional[list[str]] = None, + match: Optional[str] = None, +) -> str: + """Render a Mermaid flowchart for all duckflow annotations in repo_root. + + Returns the raw Mermaid markup as a string. + + Args: + repo_root: Absolute path to the repository root to scan. + include: Optional list of glob patterns (see duckflow_extract). + match: Optional substring filter to restrict which entries appear + in the rendered diagram. + """ + try: + root = Path(repo_root) + if not root.exists(): + return f"ERROR: repo_root does not exist: {repo_root}" + entries = extract_duckflow_entries(root, include=include) + entries = filter_entries(entries, match) + graph = stitch_duckflow(entries) + return render_mermaid(graph) + except _TOOL_EXCEPTIONS as exc: + return f"ERROR: {exc}" + + +# --------------------------------------------------------------------------- +# Tool: duckflow_mermaid_text +# --------------------------------------------------------------------------- + + +@mcp.tool() +def duckflow_mermaid_text( + text: str, + path: str = "", + match: Optional[str] = None, +) -> str: + """Render a Mermaid flowchart directly from a string of source text. + + Args: + text: Source text to scan for duckflow annotations. + path: Logical file path used in node labels (default: ""). + match: Optional substring filter. + """ + try: + entries = extract_duckflow_entries_from_text(text, Path(path)) + entries = filter_entries(entries, match) + graph = stitch_duckflow(entries) + return render_mermaid(graph) + except _TOOL_EXCEPTIONS as exc: + return f"ERROR: {exc}" + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..4a9e5ea --- /dev/null +++ b/install.sh @@ -0,0 +1,569 @@ +#!/usr/bin/env bash +set -euo pipefail + +BLUE='\033[0;34m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_SOURCE="$SCRIPT_DIR" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" + +SELECTED_TOOLS=() +INSTALL_SCOPE="" +PROJECT_DIR="" +UPDATED_PATHS=() + +print_header() { + echo -e "${BLUE}duckflow-mcp installer${NC}" + echo "======================" + echo "" + echo "This script installs the duckflow-mcp MCP server from the current checkout:" + echo " $REPO_SOURCE" + echo "" + echo "It will ask which clients you use, wire duckflow-mcp into their MCP config," + echo "and install the packaged workflow instructions into the right place." + echo "" +} + +detect_vscode_user_dir() { + case "$(uname -s)" in + Darwin*) echo "$HOME/Library/Application Support/Code/User" ;; + Linux*) echo "$HOME/.config/Code/User" ;; + CYGWIN*|MINGW*|MSYS*) echo "$HOME/AppData/Roaming/Code/User" ;; + *) + echo -e "${RED}Unsupported operating system: $(uname -s)${NC}" >&2 + exit 1 + ;; + esac +} + +detect_cline_settings_path() { + case "$(uname -s)" in + Darwin*) echo "$HOME/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json" ;; + Linux*) echo "$HOME/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json" ;; + CYGWIN*|MINGW*|MSYS*) echo "$HOME/AppData/Roaming/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json" ;; + *) + echo -e "${RED}Unsupported operating system: $(uname -s)${NC}" >&2 + exit 1 + ;; + esac +} + +say() { + echo -e "${BLUE}$1${NC}" +} + +warn() { + echo -e "${YELLOW}$1${NC}" +} + +success() { + echo -e "${GREEN}$1${NC}" +} + +require_command() { + local command_name="$1" + if ! command -v "$command_name" >/dev/null 2>&1; then + echo -e "${RED}Missing required command: $command_name${NC}" >&2 + exit 1 + fi +} + +ensure_dir() { + local dir_path="$1" + mkdir -p "$dir_path" +} + +record_path() { + UPDATED_PATHS+=("$1") +} + +backup_file() { + local file_path="$1" + if [ -f "$file_path" ]; then + local backup_path="${file_path}.bak.${TIMESTAMP}" + cp "$file_path" "$backup_path" + warn "Backed up $file_path to $backup_path" + fi +} + +copy_with_backup() { + local source_path="$1" + local target_path="$2" + + ensure_dir "$(dirname "$target_path")" + + if [ -f "$target_path" ] && cmp -s "$source_path" "$target_path"; then + success "Up to date: $target_path" + return 0 + fi + + if [ -f "$target_path" ]; then + backup_file "$target_path" + fi + + cp "$source_path" "$target_path" + success "Installed $target_path" + record_path "$target_path" +} + +merge_markdown_block() { + local source_path="$1" + local target_path="$2" + local start_marker="$3" + local end_marker="$4" + local temp_output + + ensure_dir "$(dirname "$target_path")" + temp_output="$(mktemp)" + + python3 - "$source_path" "$target_path" "$start_marker" "$end_marker" > "$temp_output" <<'PY' +from pathlib import Path +import re +import sys + +source_path, target_path, start_marker, end_marker = sys.argv[1:5] +source_text = Path(source_path).read_text() +target = Path(target_path) +target_text = target.read_text() if target.exists() else "" +block = f"{start_marker}\n{source_text.rstrip()}\n{end_marker}\n" +pattern = re.compile(re.escape(start_marker) + r".*?" + re.escape(end_marker) + r"\n?", re.S) + +if pattern.search(target_text): + updated = pattern.sub(block, target_text, count=1) +elif target_text.strip(): + updated = target_text.rstrip() + "\n\n" + block +else: + updated = block + +sys.stdout.write(updated) +PY + + if [ -f "$target_path" ] && cmp -s "$temp_output" "$target_path"; then + rm -f "$temp_output" + success "Up to date: $target_path" + return 0 + fi + + if [ -f "$target_path" ]; then + backup_file "$target_path" + fi + + mv "$temp_output" "$target_path" + + success "Merged duckflow instructions into $target_path" + record_path "$target_path" +} + +merge_vscode_mcp_server() { + local target_path="$1" + local temp_output + + ensure_dir "$(dirname "$target_path")" + temp_output="$(mktemp)" + + python3 - "$target_path" "$REPO_SOURCE" > "$temp_output" <<'PY' +import json +from pathlib import Path +import sys + +target_path = Path(sys.argv[1]) +repo_source = sys.argv[2] +if target_path.exists(): + data = json.loads(target_path.read_text()) +else: + data = {} +servers = data.get("servers") +if not isinstance(servers, dict): + servers = {} +servers["duckflow-mcp"] = { + "type": "stdio", + "command": "uvx", + "args": ["--from", repo_source, "duckflow-mcp"], +} +data["servers"] = servers +if "inputs" not in data or not isinstance(data["inputs"], list): + data["inputs"] = [] +sys.stdout.write(json.dumps(data, indent=2) + "\n") +PY + + if [ -f "$target_path" ] && cmp -s "$temp_output" "$target_path"; then + rm -f "$temp_output" + success "Up to date: $target_path" + return 0 + fi + + if [ -f "$target_path" ]; then + backup_file "$target_path" + fi + + mv "$temp_output" "$target_path" + + success "Updated VS Code MCP config at $target_path" + record_path "$target_path" +} + +merge_json_mcp_server() { + local target_path="$1" + local root_key="$2" + local temp_output + + ensure_dir "$(dirname "$target_path")" + temp_output="$(mktemp)" + + python3 - "$target_path" "$root_key" "$REPO_SOURCE" > "$temp_output" <<'PY' +import json +from pathlib import Path +import sys + +target_path = Path(sys.argv[1]) +root_key = sys.argv[2] +repo_source = sys.argv[3] +if target_path.exists(): + data = json.loads(target_path.read_text()) +else: + data = {} +bucket = data.get(root_key) +if not isinstance(bucket, dict): + bucket = {} +bucket["duckflow-mcp"] = { + "type": "stdio", + "command": "uvx", + "args": ["--from", repo_source, "duckflow-mcp"], +} +data[root_key] = bucket +sys.stdout.write(json.dumps(data, indent=2) + "\n") +PY + + if [ -f "$target_path" ] && cmp -s "$temp_output" "$target_path"; then + rm -f "$temp_output" + success "Up to date: $target_path" + return 0 + fi + + if [ -f "$target_path" ]; then + backup_file "$target_path" + fi + + mv "$temp_output" "$target_path" + + success "Updated $target_path" + record_path "$target_path" +} + +merge_codex_config() { + local target_path="$1" + local temp_output + + ensure_dir "$(dirname "$target_path")" + temp_output="$(mktemp)" + + python3 - "$target_path" "$REPO_SOURCE" > "$temp_output" <<'PY' +from pathlib import Path +import re +import sys + +target_path = Path(sys.argv[1]) +repo_source = sys.argv[2] +block = ( + '[mcp_servers.duckflow-mcp]\n' + 'command = "uvx"\n' + f'args = ["--from", "{repo_source}", "duckflow-mcp"]\n' +) +text = target_path.read_text() if target_path.exists() else "" +pattern = re.compile(r'^\[mcp_servers\.duckflow-mcp\]\n(?:^(?!\[).*$\n?)*', re.M) + +if pattern.search(text): + updated = pattern.sub(block, text, count=1) +elif text.strip(): + updated = text.rstrip() + "\n\n" + block +else: + updated = block + +sys.stdout.write(updated) +PY + + if [ -f "$target_path" ] && cmp -s "$temp_output" "$target_path"; then + rm -f "$temp_output" + success "Up to date: $target_path" + return 0 + fi + + if [ -f "$target_path" ]; then + backup_file "$target_path" + fi + + mv "$temp_output" "$target_path" + + success "Updated Codex MCP config at $target_path" + record_path "$target_path" +} + +has_tool() { + local requested="$1" + local tool_name + for tool_name in "${SELECTED_TOOLS[@]}"; do + if [ "$tool_name" = "$requested" ]; then + return 0 + fi + done + return 1 +} + +prompt_tools() { + local selection + local token + + echo "Choose the client(s) you want to configure:" + echo " 1) GitHub Copilot" + echo " 2) Codex" + echo " 3) Claude Code" + echo " 4) Cline" + echo " 5) All of the above" + echo "" + read -r -p "Enter a comma-separated list (for example 1,3 or 5): " selection + selection="${selection// /}" + + if [ -z "$selection" ] || [ "$selection" = "5" ] || [ "$selection" = "all" ] || [ "$selection" = "ALL" ]; then + SELECTED_TOOLS=("copilot" "codex" "claude" "cline") + return + fi + + IFS=',' read -r -a raw_tokens <<< "$selection" + for token in "${raw_tokens[@]}"; do + case "$token" in + 1|copilot|Copilot) SELECTED_TOOLS+=("copilot") ;; + 2|codex|Codex) SELECTED_TOOLS+=("codex") ;; + 3|claude|Claude) SELECTED_TOOLS+=("claude") ;; + 4|cline|Cline) SELECTED_TOOLS+=("cline") ;; + *) + echo -e "${RED}Unrecognized selection: $token${NC}" >&2 + exit 1 + ;; + esac + done + + if [ "${#SELECTED_TOOLS[@]}" -eq 0 ]; then + echo -e "${RED}No tools selected.${NC}" >&2 + exit 1 + fi +} + +prompt_scope() { + local selection + echo "" + echo "Choose where to install workflow instructions:" + echo " 1) User-level, when the client supports shared instructions" + echo " 2) Project-level in a target repository" + echo "" + echo "Codex, Claude Code, and Cline still use project files for workflow guidance," + echo "so the script will ask for a project directory if you select them." + echo "" + read -r -p "Enter 1 or 2 [2]: " selection + selection="${selection:-2}" + + case "$selection" in + 1) INSTALL_SCOPE="user" ;; + 2) INSTALL_SCOPE="project" ;; + *) + echo -e "${RED}Invalid scope selection: $selection${NC}" >&2 + exit 1 + ;; + esac +} + +needs_project_dir() { + if [ "$INSTALL_SCOPE" = "project" ]; then + return 0 + fi + + has_tool "codex" && return 0 + has_tool "claude" && return 0 + has_tool "cline" && return 0 + return 1 +} + +prompt_project_dir() { + local input_path + + echo "" + read -r -p "Enter the target project directory for repo-scoped instruction files: " input_path + if [ -z "$input_path" ]; then + echo -e "${RED}A project directory is required for this selection.${NC}" >&2 + exit 1 + fi + if [ ! -d "$input_path" ]; then + echo -e "${RED}Directory does not exist: $input_path${NC}" >&2 + exit 1 + fi + PROJECT_DIR="$(cd "$input_path" && pwd)" +} + +copilot_instruction_target() { + local vscode_user_dir + vscode_user_dir="$(detect_vscode_user_dir)" + + if [ "$INSTALL_SCOPE" = "user" ]; then + echo "$vscode_user_dir/copilot-instructions.md" + else + echo "$PROJECT_DIR/.github/copilot-instructions.md" + fi +} + +copilot_skill_target() { + local vscode_user_dir + vscode_user_dir="$(detect_vscode_user_dir)" + + if [ "$INSTALL_SCOPE" = "user" ]; then + echo "$vscode_user_dir/skills/duckflow/SKILL.md" + else + echo "$PROJECT_DIR/.github/skills/duckflow/SKILL.md" + fi +} + +copilot_prompt_target() { + local vscode_user_dir + vscode_user_dir="$(detect_vscode_user_dir)" + + if [ "$INSTALL_SCOPE" = "user" ]; then + echo "$vscode_user_dir/prompts/duckflow.prompt.md" + else + echo "$PROJECT_DIR/.github/prompts/duckflow.prompt.md" + fi +} + +claude_instruction_target() { + if [ -f "$PROJECT_DIR/.claude/CLAUDE.md" ] || [ -d "$PROJECT_DIR/.claude" ]; then + echo "$PROJECT_DIR/.claude/CLAUDE.md" + else + echo "$PROJECT_DIR/CLAUDE.md" + fi +} + +install_copilot() { + local vscode_user_dir + local instruction_target + local skill_target + local prompt_target + + say "Installing GitHub Copilot configuration" + vscode_user_dir="$(detect_vscode_user_dir)" + merge_vscode_mcp_server "$vscode_user_dir/mcp.json" + + instruction_target="$(copilot_instruction_target)" + skill_target="$(copilot_skill_target)" + prompt_target="$(copilot_prompt_target)" + + merge_markdown_block \ + "$SCRIPT_DIR/templates/agent-setup/copilot/copilot-instructions.md" \ + "$instruction_target" \ + "" \ + "" + copy_with_backup \ + "$SCRIPT_DIR/templates/agent-setup/copilot/skills/duckflow/SKILL.md" \ + "$skill_target" + copy_with_backup \ + "$SCRIPT_DIR/templates/agent-setup/copilot/prompts/duckflow.prompt.md" \ + "$prompt_target" + echo "" +} + +install_codex() { + local codex_config="$HOME/.codex/config.toml" + local agents_target="$PROJECT_DIR/AGENTS.md" + + say "Installing Codex configuration" + merge_codex_config "$codex_config" + merge_markdown_block \ + "$SCRIPT_DIR/templates/agent-setup/AGENTS.md" \ + "$agents_target" \ + "" \ + "" + echo "" +} + +install_claude() { + local claude_config="$HOME/.claude/settings.json" + local claude_target + + say "Installing Claude Code configuration" + merge_json_mcp_server "$claude_config" "mcpServers" + claude_target="$(claude_instruction_target)" + merge_markdown_block \ + "$SCRIPT_DIR/templates/agent-setup/CLAUDE.md" \ + "$claude_target" \ + "" \ + "" + echo "" +} + +install_cline() { + local cline_config + local cline_rules_target="$PROJECT_DIR/.clinerules" + + say "Installing Cline configuration" + cline_config="$(detect_cline_settings_path)" + merge_json_mcp_server "$cline_config" "mcpServers" + merge_markdown_block \ + "$SCRIPT_DIR/templates/agent-setup/AGENTS.md" \ + "$cline_rules_target" \ + "" \ + "" + echo "" +} + +print_plan() { + echo "Selected clients: ${SELECTED_TOOLS[*]}" + echo "Instruction scope: $INSTALL_SCOPE" + if [ -n "$PROJECT_DIR" ]; then + echo "Project directory: $PROJECT_DIR" + fi + echo "" + echo "The MCP server entries will use this checkout in uvx:" + echo " uvx --from $REPO_SOURCE duckflow-mcp" + echo "" +} + +print_summary() { + local updated_path + + echo "Installation complete." + echo "" + if [ "${#UPDATED_PATHS[@]}" -gt 0 ]; then + echo "Updated files:" + for updated_path in "${UPDATED_PATHS[@]}"; do + echo " - $updated_path" + done + echo "" + fi + + echo "If you want clients to install duckflow-mcp from the published GitHub URL instead" + echo "of this local checkout, use the manual commands in README.md and replace the" + echo "local --from path with git+https://github.com/warnes-innovations/duckflow." +} + +main() { + require_command python3 + + print_header + prompt_tools + prompt_scope + + if needs_project_dir; then + prompt_project_dir + fi + + print_plan + + has_tool "copilot" && install_copilot + has_tool "codex" && install_codex + has_tool "claude" && install_claude + has_tool "cline" && install_cline + + print_summary +} + +main "$@" diff --git a/pyproject.toml b/pyproject.toml index a294200..26fc9b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,9 +26,11 @@ classifiers = [ [project.scripts] duckflow-extract = "duckflow.cli:extract_main" duckflow-mermaid = "duckflow.cli:mermaid_main" +duckflow-mcp = "duckflow.server:main" [project.optional-dependencies] dev = ["pytest>=8.0"] +mcp = ["mcp>=1.0"] [tool.setuptools.packages.find] include = ["duckflow*"] diff --git a/templates/agent-setup/AGENTS.md b/templates/agent-setup/AGENTS.md new file mode 100644 index 0000000..69783c7 --- /dev/null +++ b/templates/agent-setup/AGENTS.md @@ -0,0 +1,44 @@ +# Duckflow Guidelines + +Use the duckflow MCP tools for all annotation extraction, stitching, and Mermaid rendering work in this repository. + +## When To Use Duckflow + +- Use duckflow when you need to add or update `duckflow:` comment annotations in source files. +- Use duckflow when you want to extract all annotations from a repository and inspect them as structured data. +- Use duckflow when you need to visualize data flow between components as a Mermaid flowchart. +- Use duckflow when reviewing whether annotations still match current code behavior. +- Use duckflow when regenerating stitched graph artifacts after annotation changes. +- Prefer normal file reads for small single-file questions that do not require graph analysis. + +## Required Workflow + +- Use `duckflow_extract` to obtain normalized annotation entries from a repository. +- Use `duckflow_extract_text` when source text is already available in the conversation. +- Use `duckflow_stitch` to build the full call and data-flow graph. +- Use `duckflow_mermaid` to render a Mermaid flowchart for a repository. +- Use `duckflow_mermaid_text` to render Mermaid markup directly from source text. +- Pass the `match` argument to narrow results to a specific flow or component. +- Pass `stitched=true` to `duckflow_extract` when you need edges alongside nodes. + +## Annotation Conventions + +- Annotations are JSON objects in source comments after the `duckflow:` marker. +- Required fields: `id` (stable unique string), `kind` (role label such as `ui`, `api`, `state`). +- Optional fields: `status` (`live`, `planned`, `shared`), `handles`, `calls`, `reads`, `writes`, `returns`, `notes`. +- Keep annotations adjacent to the code block they describe. +- Record only facts visible in the local block; do not narrate whole workflows. +- Use exact string tokens for all token fields so the graph builder can stitch by equality. +- Set `status` accurately when code paths exist in parallel live and planned implementations. + +## Graph Stitching Rules + +- Control edge: a `calls` token in one entry matches a `handles` token in another. +- Data edge: a `writes` or `returns` token in one entry matches a `reads` token in another. +- Edges are only created between entries with compatible statuses (same status, or either side is `shared`). + +## Maintenance Rules + +- After modifying annotations, re-run extraction and Mermaid generation to verify the graph is consistent. +- Prefer a small number of stable tokens over prose descriptions. +- When two implementations mirror the same local flow, annotate both and set `status` accurately. diff --git a/templates/agent-setup/CLAUDE.md b/templates/agent-setup/CLAUDE.md new file mode 100644 index 0000000..69783c7 --- /dev/null +++ b/templates/agent-setup/CLAUDE.md @@ -0,0 +1,44 @@ +# Duckflow Guidelines + +Use the duckflow MCP tools for all annotation extraction, stitching, and Mermaid rendering work in this repository. + +## When To Use Duckflow + +- Use duckflow when you need to add or update `duckflow:` comment annotations in source files. +- Use duckflow when you want to extract all annotations from a repository and inspect them as structured data. +- Use duckflow when you need to visualize data flow between components as a Mermaid flowchart. +- Use duckflow when reviewing whether annotations still match current code behavior. +- Use duckflow when regenerating stitched graph artifacts after annotation changes. +- Prefer normal file reads for small single-file questions that do not require graph analysis. + +## Required Workflow + +- Use `duckflow_extract` to obtain normalized annotation entries from a repository. +- Use `duckflow_extract_text` when source text is already available in the conversation. +- Use `duckflow_stitch` to build the full call and data-flow graph. +- Use `duckflow_mermaid` to render a Mermaid flowchart for a repository. +- Use `duckflow_mermaid_text` to render Mermaid markup directly from source text. +- Pass the `match` argument to narrow results to a specific flow or component. +- Pass `stitched=true` to `duckflow_extract` when you need edges alongside nodes. + +## Annotation Conventions + +- Annotations are JSON objects in source comments after the `duckflow:` marker. +- Required fields: `id` (stable unique string), `kind` (role label such as `ui`, `api`, `state`). +- Optional fields: `status` (`live`, `planned`, `shared`), `handles`, `calls`, `reads`, `writes`, `returns`, `notes`. +- Keep annotations adjacent to the code block they describe. +- Record only facts visible in the local block; do not narrate whole workflows. +- Use exact string tokens for all token fields so the graph builder can stitch by equality. +- Set `status` accurately when code paths exist in parallel live and planned implementations. + +## Graph Stitching Rules + +- Control edge: a `calls` token in one entry matches a `handles` token in another. +- Data edge: a `writes` or `returns` token in one entry matches a `reads` token in another. +- Edges are only created between entries with compatible statuses (same status, or either side is `shared`). + +## Maintenance Rules + +- After modifying annotations, re-run extraction and Mermaid generation to verify the graph is consistent. +- Prefer a small number of stable tokens over prose descriptions. +- When two implementations mirror the same local flow, annotate both and set `status` accurately. diff --git a/templates/agent-setup/copilot/copilot-instructions.md b/templates/agent-setup/copilot/copilot-instructions.md new file mode 100644 index 0000000..b094454 --- /dev/null +++ b/templates/agent-setup/copilot/copilot-instructions.md @@ -0,0 +1,45 @@ +# Duckflow Guidelines + +Use the duckflow MCP tools for all annotation extraction, stitching, and Mermaid rendering work in this workspace. + +## When To Use Duckflow + +- Use duckflow when you need to add or update `duckflow:` comment annotations in source files. +- Use duckflow when you want to extract all annotations from a repository and inspect them as structured data. +- Use duckflow when you need to visualize data flow between components as a Mermaid flowchart. +- Use duckflow when reviewing whether annotations still match current code behavior. +- Use duckflow when regenerating stitched graph artifacts after annotation changes. +- Use the `duckflow` skill when available to decide whether the task should use MCP extraction. +- Prefer normal file reads for small single-file questions that do not require graph analysis. + +## Required Workflow + +- Use `duckflow_extract` to obtain normalized annotation entries from a repository. +- Use `duckflow_extract_text` when source text is already available in the conversation. +- Use `duckflow_stitch` to build the full call and data-flow graph. +- Use `duckflow_mermaid` to render a Mermaid flowchart for a repository. +- Use `duckflow_mermaid_text` to render Mermaid markup directly from source text. +- Pass the `match` argument to narrow results to a specific flow or component. +- Pass `stitched=true` to `duckflow_extract` when you need edges alongside nodes. + +## Annotation Conventions + +- Annotations are JSON objects in source comments after the `duckflow:` marker. +- Required fields: `id` (stable unique string), `kind` (role label such as `ui`, `api`, `state`). +- Optional fields: `status` (`live`, `planned`, `shared`), `handles`, `calls`, `reads`, `writes`, `returns`, `notes`. +- Keep annotations adjacent to the code block they describe. +- Record only facts visible in the local block; do not narrate whole workflows. +- Use exact string tokens for all token fields so the graph builder can stitch by equality. +- Set `status` accurately when code paths exist in parallel live and planned implementations. + +## Graph Stitching Rules + +- Control edge: a `calls` token in one entry matches a `handles` token in another. +- Data edge: a `writes` or `returns` token in one entry matches a `reads` token in another. +- Edges are only created between entries with compatible statuses (same status, or either side is `shared`). + +## Maintenance Rules + +- After modifying annotations, re-run extraction and Mermaid generation to verify the graph is consistent. +- Prefer a small number of stable tokens over prose descriptions. +- When two implementations mirror the same local flow, annotate both and set `status` accurately. diff --git a/templates/agent-setup/copilot/prompts/duckflow.prompt.md b/templates/agent-setup/copilot/prompts/duckflow.prompt.md new file mode 100644 index 0000000..6339c27 --- /dev/null +++ b/templates/agent-setup/copilot/prompts/duckflow.prompt.md @@ -0,0 +1,57 @@ +--- +description: "Add, review, or regenerate duckflow annotations and Mermaid graph artifacts" +name: "Duckflow Annotation" +argument-hint: "Describe the file, flow, or task such as: annotate web/foo.js, review changed duckflow files, or regenerate summary flow" +agent: "agent" +tools: [duckflow-mcp/*] +--- + +Use the `duckflow-mcp` MCP server to work with duckflow annotations in the current workspace. + +Follow this workflow: + +1. Determine whether the request is to **add**, **update**, **review**, or **regenerate** duckflow annotations. +2. If ambiguous, ask for the target file, flow name, or task description. +3. Read repo-local duckflow conventions from `DUCKFLOW.md`, `.github/copilot-instructions.md`, or existing annotations if present. + +### Add annotations + +4. Read the target source file(s) and identify the code blocks to annotate. +5. For each block, record only locally visible facts: what it handles, calls, reads, writes, or returns. +6. Choose a stable `id` and a concise `kind` label. +7. Write the annotation as a JSON object in a comment immediately above the code block. +8. Call `duckflow_extract_text` on the updated file content to verify the annotation parses correctly. + +### Update annotations + +4. Call `duckflow_extract` to surface all current annotations and their tokens. +5. Identify stale or incorrect fields by comparing annotations against current code. +6. Revise tokens, status values, adjacency, or notes as needed. +7. Re-verify with `duckflow_extract_text` after each change. + +### Review annotations + +4. Call `duckflow_extract` to obtain all current entries. +5. Compare each annotation against the corresponding code block. +6. Flag stale, misleading, or missing annotations. +7. Propose edits; apply only after explicit user approval. + +### Regenerate graph artifacts + +4. Call `duckflow_mermaid` to render the full Mermaid flowchart. +5. Use `match` to narrow the diagram to a specific flow if requested. +6. Show the Mermaid output and summarise any graph changes. + +### Final step (all tasks) + +9. Report: files changed, flows touched, regeneration commands run, and any follow-up gaps. + +Rules: + +- Record only facts visible in the local code block; do not narrate whole workflows. +- Use exact string tokens for all `handles`, `calls`, `reads`, `writes`, and `returns` fields. +- Set `status` (`live`, `planned`, `shared`) accurately when parallel implementations exist. +- Prefer stable tokens over prose; avoid vague tokens like `data:input` or `step:done`. +- If the MCP server is unavailable, fall back to the CLI commands and tell the user. + +Input: ${input} diff --git a/templates/agent-setup/copilot/skills/duckflow/SKILL.md b/templates/agent-setup/copilot/skills/duckflow/SKILL.md new file mode 100644 index 0000000..6844512 --- /dev/null +++ b/templates/agent-setup/copilot/skills/duckflow/SKILL.md @@ -0,0 +1,68 @@ +--- +name: duckflow +description: > + Extract, stitch, and render duckflow annotations. + Use when tracing local data flow, adding or updating comment annotations, + reviewing whether annotations match current code, or regenerating Mermaid + flowchart artifacts. +--- + +Annotate and analyse data-flow through source code using the `duckflow-mcp` MCP server. + +## When to Use + +- The user wants to add `duckflow:` comment annotations to source files. +- The user wants to extract all annotations from a repository. +- The user wants to visualise component data flow as a Mermaid flowchart. +- The user wants to review whether annotations still match current code. +- The user wants to regenerate stitched graph artifacts after annotation changes. + +## MCP Tool Operations + +Prefer the `duckflow-mcp` MCP tools when the server is available. +Do not read or write duckflow annotations manually when an MCP tool can perform the operation. +If `duckflow-mcp` is unavailable, fall back to running the CLI tools. + +| Operation | How | +|-----------|-----| +| Extract entries from repo | `duckflow_extract(repo_root, include?, match?, stitched?)` | +| Extract entries from text | `duckflow_extract_text(text, path?, match?)` | +| Build stitched graph | `duckflow_stitch(repo_root, include?, match?)` | +| Render Mermaid (repo) | `duckflow_mermaid(repo_root, include?, match?)` | +| Render Mermaid (text) | `duckflow_mermaid_text(text, path?, match?)` | + +## CLI Fallback + +```bash +duckflow-extract --repo-root . +duckflow-extract --repo-root . --stitched +duckflow-mermaid --repo-root . +duckflow-mermaid --repo-root . --match summary +``` + +## Annotation Schema + +```json +{ + "id": "summary.api.generate", + "kind": "api", + "status": "live", + "handles": ["POST /api/generate-summary"], + "writes": ["state:session_summaries.ai_generated"], + "returns": ["response:POST /api/generate-summary.summary"] +} +``` + +Required fields: `id`, `kind`. +Optional fields: `status` (`live`, `planned`, `shared`), `handles`, `calls`, `reads`, `writes`, `returns`, `notes`. + +## Workflow Summary + +1. Identify the annotation task: add, update, review, or regenerate. +2. Read repo-local conventions if a `DUCKFLOW.md` or `.github/copilot-instructions.md` exists. +3. Inspect the target source block; record only locally visible facts. +4. Apply the annotation using stable, exact tokens. +5. Verify the annotation is valid JSON inside a comment. +6. Re-extract or re-render to confirm the graph is consistent. + +For the full conversational workflow, use the packaged `/duckflow` prompt at `.github/prompts/duckflow.prompt.md`. diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..460c417 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Gregory R. Warnes +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the duckflow MCP server tools.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from duckflow.server import ( + duckflow_extract, + duckflow_extract_text, + duckflow_mermaid, + duckflow_mermaid_text, + duckflow_stitch, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +PRODUCER_TEXT = """\ +# duckflow: { +# "id": "summary.api", +# "kind": "api", +# "status": "live", +# "handles": ["POST /api/generate-summary"], +# "writes": ["state:session_summaries.ai_generated"], +# "returns": ["response:POST /api/generate-summary.summary"] +# } +def handler(): + return None +""" + +CONSUMER_TEXT = """\ +// duckflow: { +// "id": "summary.ui", +// "kind": "ui", +// "status": "live", +// "calls": ["POST /api/generate-summary"], +// "reads": ["response:POST /api/generate-summary.summary"] +// } +""" + +PLANNED_TEXT = """\ +# duckflow: { +# "id": "planned.route", +# "kind": "api", +# "status": "planned", +# "reads": ["state:summary_focus_override"] +# } +""" + +LIVE_WRITER_TEXT = """\ +# duckflow: { +# "id": "live.route", +# "kind": "api", +# "status": "live", +# "writes": ["state:summary_focus_override"] +# } +""" + + +@pytest.fixture() +def repo_with_annotations(tmp_path: Path) -> Path: + """Create a minimal repo with two annotated source files.""" + src = tmp_path / "src" + src.mkdir() + (src / "api.py").write_text(PRODUCER_TEXT, encoding="utf-8") + (src / "ui.js").write_text(CONSUMER_TEXT, encoding="utf-8") + return tmp_path + + +@pytest.fixture() +def empty_repo(tmp_path: Path) -> Path: + """Create a repo with no duckflow annotations.""" + (tmp_path / "main.py").write_text("x = 1\n", encoding="utf-8") + return tmp_path + + +# --------------------------------------------------------------------------- +# duckflow_extract_text +# --------------------------------------------------------------------------- + + +class TestDuckflowExtractText: + def test_returns_json_list(self) -> None: + result = duckflow_extract_text(PRODUCER_TEXT) + entries = json.loads(result) + assert isinstance(entries, list) + assert len(entries) == 1 + + def test_entry_fields(self) -> None: + result = duckflow_extract_text(PRODUCER_TEXT) + entry = json.loads(result)[0] + assert entry["id"] == "summary.api" + assert entry["kind"] == "api" + assert entry["status"] == "live" + assert "POST /api/generate-summary" in entry["handles"] + + def test_match_filter_keeps_matching_entry(self) -> None: + combined = PRODUCER_TEXT + "\n" + CONSUMER_TEXT + result = duckflow_extract_text(combined, path="combined.py", match="api") + entries = json.loads(result) + ids = [e["id"] for e in entries] + assert "summary.api" in ids + + def test_match_filter_excludes_non_matching(self) -> None: + combined = PRODUCER_TEXT + "\n" + CONSUMER_TEXT + result = duckflow_extract_text(combined, path="combined.py", match="summary.api") + entries = json.loads(result) + ids = [e["id"] for e in entries] + assert "summary.ui" not in ids + + def test_no_annotations_returns_empty_list(self) -> None: + result = duckflow_extract_text("x = 1\n") + assert json.loads(result) == [] + + def test_invalid_json_returns_error(self) -> None: + bad = "# duckflow: {broken json\n" + result = duckflow_extract_text(bad) + assert result.startswith("ERROR:") + + def test_path_stored_in_entry(self) -> None: + result = duckflow_extract_text(PRODUCER_TEXT, path="mymodule.py") + entry = json.loads(result)[0] + assert entry["path"] == "mymodule.py" + + def test_default_path_is_inline(self) -> None: + result = duckflow_extract_text(PRODUCER_TEXT) + entry = json.loads(result)[0] + assert entry["path"] == "" + + +# --------------------------------------------------------------------------- +# duckflow_extract +# --------------------------------------------------------------------------- + + +class TestDuckflowExtract: + def test_extracts_entries_from_repo( + self, repo_with_annotations: Path + ) -> None: + result = duckflow_extract(str(repo_with_annotations)) + entries = json.loads(result) + assert isinstance(entries, list) + assert len(entries) == 2 + ids = {e["id"] for e in entries} + assert ids == {"summary.api", "summary.ui"} + + def test_include_glob_restricts_scan( + self, repo_with_annotations: Path + ) -> None: + result = duckflow_extract( + str(repo_with_annotations), include=["**/*.py"] + ) + entries = json.loads(result) + assert len(entries) == 1 + assert entries[0]["id"] == "summary.api" + + def test_match_filter(self, repo_with_annotations: Path) -> None: + result = duckflow_extract(str(repo_with_annotations), match="summary.api") + entries = json.loads(result) + ids = [e["id"] for e in entries] + assert "summary.api" in ids + assert "summary.ui" not in ids + + def test_stitched_flag_returns_graph( + self, repo_with_annotations: Path + ) -> None: + result = duckflow_extract(str(repo_with_annotations), stitched=True) + graph = json.loads(result) + assert "nodes" in graph + assert "edges" in graph + + def test_stitched_graph_has_expected_edges( + self, repo_with_annotations: Path + ) -> None: + result = duckflow_extract(str(repo_with_annotations), stitched=True) + graph = json.loads(result) + edge_kinds = {e["kind"] for e in graph["edges"]} + assert "call" in edge_kinds + assert "data" in edge_kinds + + def test_empty_repo_returns_empty_list(self, empty_repo: Path) -> None: + result = duckflow_extract(str(empty_repo)) + assert json.loads(result) == [] + + def test_nonexistent_repo_returns_error(self) -> None: + result = duckflow_extract("/nonexistent/path/does/not/exist") + assert result.startswith("ERROR:") + + +# --------------------------------------------------------------------------- +# duckflow_stitch +# --------------------------------------------------------------------------- + + +class TestDuckflowStitch: + def test_returns_graph_with_nodes_and_edges( + self, repo_with_annotations: Path + ) -> None: + result = duckflow_stitch(str(repo_with_annotations)) + graph = json.loads(result) + assert "nodes" in graph + assert "edges" in graph + + def test_call_edge_present(self, repo_with_annotations: Path) -> None: + result = duckflow_stitch(str(repo_with_annotations)) + graph = json.loads(result) + call_edges = [e for e in graph["edges"] if e["kind"] == "call"] + assert any( + e["token"] == "POST /api/generate-summary" for e in call_edges + ) + + def test_data_edge_present(self, repo_with_annotations: Path) -> None: + result = duckflow_stitch(str(repo_with_annotations)) + graph = json.loads(result) + data_edges = [e for e in graph["edges"] if e["kind"] == "data"] + assert any( + e["token"] == "response:POST /api/generate-summary.summary" + for e in data_edges + ) + + def test_match_narrows_nodes(self, repo_with_annotations: Path) -> None: + result = duckflow_stitch(str(repo_with_annotations), match="summary.api") + graph = json.loads(result) + node_ids = [n["id"] for n in graph["nodes"]] + assert "summary.api" in node_ids + assert "summary.ui" not in node_ids + + def test_incompatible_statuses_produce_no_edges( + self, tmp_path: Path + ) -> None: + (tmp_path / "live.py").write_text(LIVE_WRITER_TEXT, encoding="utf-8") + (tmp_path / "planned.py").write_text(PLANNED_TEXT, encoding="utf-8") + result = duckflow_stitch(str(tmp_path)) + graph = json.loads(result) + assert graph["edges"] == [] + + def test_empty_repo_returns_empty_graph(self, empty_repo: Path) -> None: + result = duckflow_stitch(str(empty_repo)) + graph = json.loads(result) + assert graph["nodes"] == [] + assert graph["edges"] == [] + + +# --------------------------------------------------------------------------- +# duckflow_mermaid +# --------------------------------------------------------------------------- + + +class TestDuckflowMermaid: + def test_returns_flowchart_header( + self, repo_with_annotations: Path + ) -> None: + result = duckflow_mermaid(str(repo_with_annotations)) + assert result.startswith("flowchart TD") + + def test_contains_node_ids(self, repo_with_annotations: Path) -> None: + result = duckflow_mermaid(str(repo_with_annotations)) + assert "summary.api" in result + assert "summary.ui" in result + + def test_contains_edge_label(self, repo_with_annotations: Path) -> None: + result = duckflow_mermaid(str(repo_with_annotations)) + assert "POST /api/generate-summary" in result + + def test_match_narrows_diagram(self, repo_with_annotations: Path) -> None: + result = duckflow_mermaid(str(repo_with_annotations), match="summary.api") + assert "summary.api" in result + assert "summary.ui" not in result + + def test_classdefs_included(self, repo_with_annotations: Path) -> None: + result = duckflow_mermaid(str(repo_with_annotations)) + assert "classDef live" in result + assert "classDef planned" in result + + def test_status_class_applied(self, tmp_path: Path) -> None: + (tmp_path / "planned.py").write_text(PLANNED_TEXT, encoding="utf-8") + result = duckflow_mermaid(str(tmp_path)) + assert "class planned.route planned;" in result + + def test_nonexistent_repo_returns_error(self) -> None: + result = duckflow_mermaid("/nonexistent/path/does/not/exist") + assert result.startswith("ERROR:") + + +# --------------------------------------------------------------------------- +# duckflow_mermaid_text +# --------------------------------------------------------------------------- + + +class TestDuckflowMermaidText: + def test_returns_flowchart_header(self) -> None: + result = duckflow_mermaid_text(PRODUCER_TEXT) + assert result.startswith("flowchart TD") + + def test_contains_node_id(self) -> None: + result = duckflow_mermaid_text(PRODUCER_TEXT) + assert "summary.api" in result + + def test_match_filter(self) -> None: + combined = PRODUCER_TEXT + "\n" + CONSUMER_TEXT + result = duckflow_mermaid_text(combined, match="summary.api") + assert "summary.api" in result + assert "summary.ui" not in result + + def test_no_annotations_returns_flowchart_header_only(self) -> None: + result = duckflow_mermaid_text("x = 1\n") + assert "flowchart TD" in result + + def test_invalid_json_returns_error(self) -> None: + bad = "# duckflow: {broken\n" + result = duckflow_mermaid_text(bad) + assert result.startswith("ERROR:") + + def test_planned_status_in_mermaid(self) -> None: + result = duckflow_mermaid_text(PLANNED_TEXT) + assert "class planned.route planned;" in result