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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion SKILL-QUICK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
67 changes: 67 additions & 0 deletions docs/design/0313-flow-diff.md
Original file line number Diff line number Diff line change
@@ -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.
104 changes: 104 additions & 0 deletions scripts/commands/flow_diff.py
Original file line number Diff line number Diff line change
@@ -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),
}
10 changes: 10 additions & 0 deletions scripts/commands/impact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions scripts/formatters/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,44 @@ 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)

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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_command_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading