diff --git a/README.md b/README.md index 9e9304f..4a5e28b 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ CodeLens consolidates what used to be ~78 separate commands into **12 umbrella c | `audit` | dead-code · complexity · smell · staleness · perf-hint · side-effect · css · a11y (default: all) | Code quality. `dead-code` cross-checked against `context --check trace` before you trust it. `css` = deep CSS analysis, `a11y` = WCAG 2.1 accessibility. | | `security` | secrets · vuln-scan · taint · binary-scan · regex-audit (default: all) | Hardcoded secrets, CVE/OSV dependency scanning, AST taint analysis, ReDoS. **Taint is Python/JS/TS/TSX only** — no Rust source/sink rules yet. | | `summary` | summary (default) · dashboard · arch-metrics · architecture | Prioritized, anti-overload findings digest. Use `--lite` — it's designed to still be big without it. | -| `impact` | impact (default) · diff · dataflow | Blast-radius analysis before you touch something. | +| `impact` | impact (default) · diff · dataflow · flow-diff | Blast-radius analysis before you touch something; `flow-diff --name X` shows whether a named `@FLOW`'s shape changed between two snapshots. | | `api-map` | api-map (default) · graph-schema | HTTP/IPC route inventory, auth-middleware coverage, cheap graph-shape introspection. | | `doctor` | doctor (default) · env-check · lsp-status | Environment/dependency health check. | | `history` | history (default) · ownership · git-status | Trend tracking across scans, git blame ownership, scan staleness vs. HEAD. | diff --git a/SKILL-QUICK.md b/SKILL-QUICK.md index 21aba54..25bb128 100755 --- a/SKILL-QUICK.md +++ b/SKILL-QUICK.md @@ -110,7 +110,7 @@ codelens search "pattern" . --mode regex --limit 5 --offset 10 --format compact | `audit [workspace]` | dead-code · complexity · smell · staleness · perf-hint · side-effect · css · a11y | | `security [workspace]` | secrets · vuln-scan · taint · binary-scan · regex-audit | | `summary [workspace]` | summary (default) · dashboard · arch-metrics · architecture | -| `impact [workspace]` | impact (`--name X`, default) · diff · dataflow | +| `impact [workspace]` | impact (`--name X`, default) · diff · dataflow · flow-diff (`--name X`, named `@FLOW` shape change) | | `api-map [workspace]` | api-map (default) · graph-schema | | `doctor [workspace]` | doctor (default) · env-check · lsp-status | | `history [workspace]` | history (default) · ownership · git-status | diff --git a/SKILL.md b/SKILL.md index 98b7bea..8e79db5 100755 --- a/SKILL.md +++ b/SKILL.md @@ -48,7 +48,7 @@ codelens guard --pre --file X → DROPPED | `audit` | dead-code · complexity · smell · staleness · perf-hint · side-effect · css (deep CSS) · a11y (WCAG 2.1) (default: all) | | `security` | secrets · vuln-scan · taint · binary-scan · regex-audit (default: all) | | `summary` | summary (default) · dashboard · arch-metrics · architecture | -| `impact` | impact (default) · diff · dataflow | +| `impact` | impact (default) · diff · dataflow · flow-diff (named `@FLOW` shape change between two snapshots, `--name X`) | | `api-map` | api-map (default) · graph-schema | | `doctor` | doctor (default) · env-check · lsp-status | | `history` | history (default) · ownership · git-status | diff --git a/docs/design/0313-flow-diff.md b/docs/design/0313-flow-diff.md new file mode 100644 index 0000000..3901bcd --- /dev/null +++ b/docs/design/0313-flow-diff.md @@ -0,0 +1,67 @@ +# Design Doc: Flow-Diff (`impact --check flow-diff`) + +> **Status:** Accepted +> **Date:** 2026-07-18 +> **Author:** Wolfvin (BOS) / Claude Code +> **Related issues:** #313 +> **Composes:** #309 (flow membership), #311 (intra-flow subgraph), #297 (edge diff) + +--- + +## Problem + +Edge-diff (#297) can say "200 edges changed between these two snapshots" — true +but not actionable. Named-flow (#309/#311) can collect the PAYMENT chain — but +only as of now. Neither answers the reviewer's actual question: **did the +PAYMENT flow's shape change in this PR?** e.g. `checkout -> validate` removed and +`checkout -> charge` added means validation was bypassed. + +## Goal + +`impact --check flow-diff --name X` reports the call-edges added/removed **within +one named flow** between two graph snapshots. Read-only, deterministic, no new +engine. + +## Design + +A pure compose of three existing pieces: + +1. `tag_audit_engine.audit_tags()` → the flow's members `(file, symbol)`, from + the agent's `@FLOW: X` tags **as of now**. +2. `diff_engine.diff_snapshots()` (#297) → `added_edges` / `removed_edges` + between two snapshots, each labelled `{from, from_file, to, to_file}`. +3. Filter to **intra-flow** edges — both endpoints are members — matching the + subgraph semantics of #311. Endpoint match keys on `(file, fn)`, with the + owner stripped from an `Owner.fn` label. + +Home is the `impact` umbrella: it already exposes `--name`, `--snapshot1`, +`--snapshot2`. Only a `_CHECKS` entry + a namespace branch are added. Command +count stays 12. + +### Membership "as of now" vs edges "between snapshots" + +Flow membership comes from the current tags; edge changes come from two +snapshots. This is exactly the "compare a pre-PR snapshot against the working +tree" use case. A function that was in the flow at snapshot A but is untagged +now is not a member now, so its edges are not reported — an accepted, documented +consequence, not a bug. + +## Non-goals + +- **Intra-flow only.** An edge from a member to a non-member (the flow calling + out) is not reported unless that callee is also tagged. This keeps the signal + to the flow's own wiring and avoids stdlib/utility noise — consistent with + #311. A member→member edge is what "the flow's shape" means here. +- **No git-ref diffing.** Uses snapshot ids (like `impact --check diff`), not + git refs. +- **No writing / no auto-tagging** — unchanged from #305/#309. + +## Testing + +Pure-unit for the compose (`_fn_of` owner-strip, `_intra_flow` keeps +member↔member and drops edges leaving the flow, owner-qualified label match), +plus `execute` with the two dependencies stubbed: the validation-bypass case +(checkout→validate removed, checkout→charge added), unchanged flow, unknown +flow → available list, missing `--name` → error, and an edge leaving the flow +ignored. `diff_snapshots` itself is covered by #297 and not re-tested here. +Verified end-to-end on a real two-snapshot demo. diff --git a/scripts/commands/flow_diff.py b/scripts/commands/flow_diff.py new file mode 100644 index 0000000..ea01d1f --- /dev/null +++ b/scripts/commands/flow_diff.py @@ -0,0 +1,104 @@ +# @WHO: scripts/commands/flow_diff.py +# @WHAT: `impact --check flow-diff` — did a named flow's shape change +# @PART: command (sub-check of `impact`) +# @ENTRY: execute() +"""`impact --check flow-diff --name X` — a named flow's shape change. + +Composes three things already in the tree: flow membership from the agent's +`@FLOW: X` tags (#309), the call-graph edge diff between two snapshots (#297), +and the intra-flow filter of the subgraph (#311). The result answers a +reviewer's real question — *did the PAYMENT flow's shape change in this PR?* — +e.g. `checkout -> validate` removed and `checkout -> charge` added means +validation was bypassed. + +Flow membership is read from the tags **as of now**; the edge changes are +between two graph snapshots. That pairing is exactly "compare the pre-PR +snapshot against the current tree". Pure read-only — no engine, no scan. +""" + +from typing import Any, Dict, List, Set, Tuple + +from tag_audit_engine import audit_tags +from diff_engine import diff_snapshots + + +def add_args(parser): + """Register CLI arguments (workspace/name/snapshots carried by umbrella).""" + parser.add_argument( + "workspace", nargs="?", default=None, + help="Path to workspace root (auto-detected if omitted)", + ) + + +def _fn_of(label: str) -> str: + """The function part of an edge label (`Owner.fn` -> `fn`, `fn` -> `fn`).""" + return label.rsplit(".", 1)[-1] if label else label + + +def _intra_flow(edges: List[Dict], members: Set[Tuple[str, str]]) -> List[Dict]: + """Keep only edges whose both endpoints are flow members.""" + kept = [] + for e in edges: + src = (e.get("from_file", ""), _fn_of(e.get("from", ""))) + dst = (e.get("to_file", ""), _fn_of(e.get("to", ""))) + if src in members and dst in members: + kept.append({"from": e.get("from", ""), "to": e.get("to", "")}) + return kept + + +def execute(args, workspace) -> Dict[str, Any]: + """Diff one named flow's call-graph shape between two snapshots. + + @FLOW: FLOW_DIFF + @CALLS: tag_audit_engine.audit_tags(), diff_engine.diff_snapshots() + @MUTATES: nothing (read-only) + """ + name = getattr(args, "name", None) + if not name: + return { + "status": "error", + "error": "flow-diff needs --name X (the flow to compare)", + "error_type": "missing_argument", + } + + audit = audit_tags(workspace) + if audit.get("status") == "error": + return audit + + flows = {f["name"]: f for f in audit.get("flows", [])} + match = flows.get(name) + if match is None: + available = sorted(flows) + return { + "status": "ok", "flow": name, "found": False, + "available_flows": available, + "message": f"No @FLOW: {name} tag found. Known flows: " + + (", ".join(available) if available else "(none)"), + } + + members: Set[Tuple[str, str]] = { + (m.get("file", ""), m.get("symbol", "")) + for m in match.get("members", []) if m.get("symbol") + } + + diff = diff_snapshots( + workspace, + getattr(args, "snapshot1", None), + getattr(args, "snapshot2", None), + ) + backend = diff.get("backend", {}) + added = _intra_flow(backend.get("added_edges", []), members) + removed = _intra_flow(backend.get("removed_edges", []), members) + + return { + "status": "ok", + "workspace": workspace, + "flow": name, + "found": True, + "snapshot_1": diff.get("snapshot_1"), + "snapshot_2": diff.get("snapshot_2"), + "member_count": len(members), + "added_edges": added, + "removed_edges": removed, + "changed": bool(added or removed), + } diff --git a/scripts/commands/impact.py b/scripts/commands/impact.py index f36b31f..3c54603 100644 --- a/scripts/commands/impact.py +++ b/scripts/commands/impact.py @@ -40,6 +40,10 @@ "module": "commands.dataflow", "help": "Trace data flow source→sink with cross-file call graph", }, + "flow-diff": { + "module": "commands.flow_diff", + "help": "Did a named @FLOW's shape change between two snapshots (--name X; issue #313)", + }, } ALL_CHECKS = list(_CHECKS.keys()) @@ -53,10 +57,12 @@ def add_args(parser): " impact Analyze change impact for a symbol (default)\n" " diff Compare registry snapshots (--git-aware for git-diff delta)\n" " dataflow Trace data flow source→sink with cross-file call graph\n" + " flow-diff Did a named @FLOW's shape change between two snapshots (--name X, issue #313)\n" "\n" "Examples:\n" " codelens impact . --name handleAuth # impact (default)\n" " codelens impact . --check diff --git-aware\n" + " codelens impact . --check flow-diff --name PAYMENT\n" " codelens impact . --check dataflow --source src/api.ts --sink db.query\n" ) parser.add_argument("workspace", nargs="?", default=None, @@ -194,6 +200,10 @@ def _build_namespace(base_args, check_name: str) -> argparse.Namespace: ns.snapshot2 = getattr(base_args, "snapshot2", None) ns.list_snapshots = getattr(base_args, "list_snapshots", False) ns.git_aware = getattr(base_args, "git_aware", False) + elif check_name == "flow-diff": + ns.name = getattr(base_args, "name", None) + ns.snapshot1 = getattr(base_args, "snapshot1", None) + ns.snapshot2 = getattr(base_args, "snapshot2", None) elif check_name == "dataflow": ns.source = getattr(base_args, "source", None) ns.sink = getattr(base_args, "sink", None) diff --git a/scripts/formatters/markdown.py b/scripts/formatters/markdown.py index 37e944a..8d940ff 100644 --- a/scripts/formatters/markdown.py +++ b/scripts/formatters/markdown.py @@ -143,6 +143,8 @@ def to_markdown(data: Any, command: str = "") -> str: _md_tags(data, lines) elif command == "flow": _md_flow(data, lines) + elif command == "flow-diff": + _md_flow_diff(data, lines) else: # Generic markdown for any command _md_generic(data, lines) @@ -150,6 +152,35 @@ def to_markdown(data: Any, command: str = "") -> str: return "\n".join(lines) +def _md_flow_diff(data: Dict, lines: list) -> None: + """Markdown for a named flow's shape change (`flow-diff`, issue #313).""" + name = data.get("flow", "") + if not data.get("found"): + lines.append(f"## Flow-diff: `{name}` — not found") + lines.append("") + lines.append(data.get("message", "")) + return + + added = data.get("added_edges", []) + removed = data.get("removed_edges", []) + s1, s2 = data.get("snapshot_1", "?"), data.get("snapshot_2", "?") + lines.append(f"## Flow-diff: `{name}` ({s1} → {s2})") + lines.append("") + if not added and not removed: + lines.append("_No shape change — the flow's internal call-edges are unchanged._") + return + if removed: + lines.append("### Removed edges") + for e in removed: + lines.append(f"- `{e.get('from', '')}` → `{e.get('to', '')}`") + lines.append("") + if added: + lines.append("### Added edges") + for e in added: + lines.append(f"- `{e.get('from', '')}` → `{e.get('to', '')}`") + lines.append("") + + def _md_flow(data: Dict, lines: list) -> None: """Markdown for the named-flow view (`context --check flow`, issue #309).""" def _member_line(m: Dict) -> str: diff --git a/tests/test_command_registry.py b/tests/test_command_registry.py index 9fe7db2..a12d66f 100644 --- a/tests/test_command_registry.py +++ b/tests/test_command_registry.py @@ -42,7 +42,7 @@ def test_every_command_module_registers(): "a11y", "affected", "arch_metrics", "architecture", "binary_scan", "circular", "complexity", "css_deep", "dashboard", "dataflow", "dead_code", "dependents", "diagnostics", "diff", "env_check", - "export_snapshot", "flow", "git_status", "graph_schema", "import_snapshot", + "export_snapshot", "flow", "flow_diff", "git_status", "graph_schema", "import_snapshot", "init", "lsp_status", "orient", "outline", "ownership", "perf_hint", "query_graph", "regex_audit", "secrets", "side_effect", "smell", "staleness", "symbols_overview", "tags", "taint", "trace", "vuln_scan", diff --git a/tests/test_flow_diff.py b/tests/test_flow_diff.py new file mode 100644 index 0000000..06f454b --- /dev/null +++ b/tests/test_flow_diff.py @@ -0,0 +1,133 @@ +""" +Tests for flow-diff — a named flow's shape change (issue #313). + +Composes flow membership (#309) with the edge diff (#297), filtered to +intra-flow edges (#311). diff_snapshots itself is covered by #297, so these +tests exercise the compose: the intra-flow filter and execute's branches, +with the two dependencies stubbed for control. +""" + +import os +import sys + +import pytest + +SCRIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts") +sys.path.insert(0, SCRIPT_DIR) + +from commands import flow_diff # noqa: E402 + + +# ─── pure helpers ──────────────────────────────────────── + +def test_fn_of_strips_owner(): + assert flow_diff._fn_of("Checkout.charge") == "charge" + assert flow_diff._fn_of("charge") == "charge" + assert flow_diff._fn_of("") == "" + + +def test_intra_flow_keeps_only_edges_between_members(): + members = {("a.py", "checkout"), ("a.py", "validate"), ("a.py", "charge")} + edges = [ + {"from": "checkout", "from_file": "a.py", "to": "validate", "to_file": "a.py"}, + {"from": "validate", "from_file": "a.py", "to": "logger", "to_file": "b.py"}, # leaves flow + ] + kept = flow_diff._intra_flow(edges, members) + assert kept == [{"from": "checkout", "to": "validate"}] + + +def test_intra_flow_matches_owner_qualified_label(): + members = {("a.py", "charge"), ("a.py", "send")} + edges = [{"from": "Checkout.charge", "from_file": "a.py", + "to": "Gateway.send", "to_file": "a.py"}] + kept = flow_diff._intra_flow(edges, members) + assert kept == [{"from": "Checkout.charge", "to": "Gateway.send"}] + + +# ─── execute (deps stubbed) ────────────────────────────── + +class _Args: + def __init__(self, name=None, snapshot1=None, snapshot2=None): + self.name = name + self.snapshot1 = snapshot1 + self.snapshot2 = snapshot2 + + +def _stub(monkeypatch, flows, diff): + monkeypatch.setattr(flow_diff, "audit_tags", lambda ws: {"status": "ok", "flows": flows}) + monkeypatch.setattr(flow_diff, "diff_snapshots", lambda ws, s1, s2: diff) + + +def test_missing_name_is_an_error(): + out = flow_diff.execute(_Args(name=None), "/ws") + assert out["status"] == "error" + assert out["error_type"] == "missing_argument" + + +def test_unknown_flow_lists_available(monkeypatch): + _stub(monkeypatch, flows=[{"name": "AUTH", "members": []}], diff={}) + out = flow_diff.execute(_Args(name="PAYMENT"), "/ws") + assert out["found"] is False + assert "AUTH" in out["available_flows"] + + +def test_validation_bypass_is_detected(monkeypatch): + flows = [{ + "name": "PAYMENT", + "members": [ + {"symbol": "checkout", "file": "a.py", "line": 1}, + {"symbol": "validate", "file": "a.py", "line": 7}, + {"symbol": "charge", "file": "a.py", "line": 13}, + ], + }] + diff = { + "snapshot_1": "S1", "snapshot_2": "S2", + "backend": { + "added_edges": [ + {"from": "checkout", "from_file": "a.py", "to": "charge", "to_file": "a.py"}, + ], + "removed_edges": [ + {"from": "checkout", "from_file": "a.py", "to": "validate", "to_file": "a.py"}, + ], + }, + } + _stub(monkeypatch, flows, diff) + + out = flow_diff.execute(_Args(name="PAYMENT"), "/ws") + + assert out["changed"] is True + assert out["added_edges"] == [{"from": "checkout", "to": "charge"}] + assert out["removed_edges"] == [{"from": "checkout", "to": "validate"}] + assert out["member_count"] == 3 + + +def test_no_change_reports_unchanged(monkeypatch): + flows = [{"name": "PAYMENT", "members": [{"symbol": "a", "file": "a.py", "line": 1}]}] + diff = {"snapshot_1": "S1", "snapshot_2": "S2", + "backend": {"added_edges": [], "removed_edges": []}} + _stub(monkeypatch, flows, diff) + + out = flow_diff.execute(_Args(name="PAYMENT"), "/ws") + assert out["changed"] is False + assert out["added_edges"] == [] + + +def test_edge_leaving_flow_is_ignored(monkeypatch): + """A member calling a non-member is not an intra-flow shape change.""" + flows = [{"name": "F", "members": [{"symbol": "a", "file": "a.py", "line": 1}]}] + diff = {"snapshot_1": "S1", "snapshot_2": "S2", + "backend": { + "added_edges": [ + {"from": "a", "from_file": "a.py", "to": "ext", "to_file": "z.py"}, + ], + "removed_edges": [], + }} + _stub(monkeypatch, flows, diff) + + out = flow_diff.execute(_Args(name="F"), "/ws") + assert out["added_edges"] == [] + assert out["changed"] is False + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])