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 @@ -109,7 +109,7 @@ CodeLens consolidates what used to be ~78 separate commands into **12 umbrella c
|---|---|---|
| `scan` | scan (default) · rescan | Build/refresh the workspace graph. Everything else depends on this having run once. |
| `search` | semantic (default) · symbol · regex · graph | The grep replacement. `pattern` comes **first**, workspace second — opposite of every other command below. See [gotcha](#a-gotcha-worth-memorizing). |
| `context` | orient (default) · outline · trace · context · diagnostics · overview · tags | Orientation, file structure, call-chain tracing, rich symbol context, LSP diagnostics (`--file`), token-efficient symbol map, `@FLOW`/`@ENTRY` doc-tag audit. |
| `context` | orient (default) · outline · trace · context · diagnostics · overview · tags · flow | Orientation, file structure, call-chain tracing, rich symbol context, LSP diagnostics (`--file`), token-efficient symbol map, `@FLOW`/`@ENTRY` doc-tag audit, named-flow collection (`--check flow --name X`). |
| `deps` | affected · dependents · circular (default: all three) · import-snapshot · export-snapshot | Dependency graph: what's affected by a change, who imports what, circular imports, team snapshot sharing. |
| `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. |
Expand Down
2 changes: 1 addition & 1 deletion SKILL-QUICK.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ codelens search "pattern" . --mode regex --limit 5 --offset 10 --format compact
|---|---|
| `scan` | scan (default) · rescan |
| `search "pattern" [workspace]` | semantic (default) · symbol · regex · graph — **pattern first, workspace second**, opposite of every command below |
| `context [workspace]` | orient (default) · outline · trace (`--name X --direction up\|down\|both`) · context (`--name X`) · diagnostics (`--file X`, LSP) · overview (symbol map) · tags (doc-tag audit) |
| `context [workspace]` | orient (default) · outline · trace (`--name X --direction up\|down\|both`) · context (`--name X`) · diagnostics (`--file X`, LSP) · overview (symbol map) · tags (doc-tag audit) · flow (`--name X`, collect named flow) |
| `deps [workspace]` | affected (`--files ...`) · dependents (`--files ...`) · circular · import-snapshot (`--input path.gz`) · export-snapshot (`--output path.gz`) |
| `audit [workspace]` | dead-code · complexity · smell · staleness · perf-hint · side-effect · css · a11y |
| `security [workspace]` | secrets · vuln-scan · taint · binary-scan · regex-audit |
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ codelens guard --pre --file X → DROPPED
|---|---|
| `scan` | scan (default) · rescan |
| `search` | semantic (default) · symbol · regex · graph — **`pattern` comes first, workspace second**, opposite of every other command here |
| `context` | orient (default) · outline · trace · context · diagnostics (LSP lint, needs `--file`) · overview (token-efficient symbol map) · tags (`@FLOW`/`@ENTRY` doc-tag audit) |
| `context` | orient (default) · outline · trace · context · diagnostics (LSP lint, needs `--file`) · overview (token-efficient symbol map) · tags (`@FLOW`/`@ENTRY` doc-tag audit) · flow (collect a named `@FLOW`'s scattered functions, `--name X`) |
| `deps` | affected · dependents · circular (default: all three) · import-snapshot · export-snapshot |
| `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) |
Expand Down
96 changes: 96 additions & 0 deletions docs/design/0309-named-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Design Doc: Named-Flow View (`context --check flow`)

> **Status:** Accepted
> **Date:** 2026-07-18
> **Author:** Wolfvin (BOS) / Claude Code
> **Related issues:** #309
> **Related PRs:** (this PR)
> **Builds on:** #305 (tag audit), #297 (edge diff — the phase-2 partner)

---

## Problem

A single logical flow — say a payment path — is implemented across scattered
functions in different files. `@FLOW: PAYMENT` tags mark them, and #305's tag
audit can *inventory* flows, but there is no way to ask the operational
question: **"show me every function in the PAYMENT flow, collected."** The
functions stay scattered; the reader reassembles the chain by hand.

Wolfvin's framing: *"1 flow dari sebuah rantai function — dengan 1 command,
semua function muncul dari yang awalnya tersebar."*

## Goal

One sub-check that collects a named flow's members into a single view, from the
`@FLOW` tags **an agent already wrote** — inventing nothing. Deterministic
(regex only, no LLM). `--name X` collects one flow; bare lists all flows.

## Architecture decision — agent writes, CodeLens stores & serves

Per Wolfvin: **not auto-detection.** The agent authors `@FLOW: NAME` in the
source (the tag is the source of truth); CodeLens owns the graph and serves the
query. This sub-check is the read-only *serve* half. It never invents a flow
name and never edits source.

Two ways to "store" the tags were considered:

- **(A) Persist tags into graph nodes at scan time** — a `flow` attribute on
each `graph_nodes` row, queryable by SQL join with the call-graph. This
touches the parser + scan pipeline across every language (backend), so per
the project's delegation rules it is a **worker task, phase 2**.
- **(B) Read tags on demand and reshape** — compose the existing
`tag_audit_engine` output flow-first at the command layer. Read-only, touches
no scan/parser code, same class as #305's tag audit.

This PR ships **(B)** as the MVP: it delivers the collected-flow view now
without backend changes. (A) is the follow-up optimization (fast SQL queries,
join with call-edges) and is the piece to delegate.

## Changes

### New file
- `scripts/commands/flow.py` — thin `execute()`; runs `audit_tags()`, reshapes
flow-first. `--name X` → one flow's members (or a not-found message listing
known flows); bare → all flows with member counts.

### Engine enhancement (`tag_audit_engine.py`)
- Each `@FLOW` tag is now attributed to its **enclosing symbol**, so a flow's
members read as function names, not bare locations. Resolution order:
1. **comment-above-def** — a def within `_LOOKAHEAD` (3) lines below the tag,
crossing only blank/comment lines (the JS idiom `// @FLOW` then `export
function charge`);
2. else the **nearest def above** (the docstring idiom — tag inside a
function body);
3. else the **file** (a true file-header tag, e.g. a `.d.ts` header block).
- `flows[]` gains `members: [{symbol, file, line}]`. `locations` is retained
unchanged for backward compat (#305 consumers + #306 ai item extraction).
- Declaration detection is a broad, language-agnostic regex (`def/fn/func/
function/class/struct/interface/type/impl/trait` + JS `const x =` / `x() {`).
A miss falls back to the file, so it need not be exhaustive.

### Registry / docs
- `commands/context.py`: register `flow` in `_CHECKS` + epilog; wire `--name`
through `_build_namespace`. Command count stays **12** (sub-check).
- `flow` added to README / SKILL / SKILL-QUICK context rows and the
`_command_registry` sub-check allowlist.

## Non-goals (explicit)

- **No writing.** Consistent with #305 — deciding a flow's membership is
authorship, which belongs to the agent.
- **No call-edges among members (yet).** Showing *how* members connect (the
flow subgraph) needs graph resolution; it is the natural phase-2 alongside
option (A) and #297 edge-diff ("did the PAYMENT flow's shape change?").
- **Enclosing-symbol resolution is heuristic**, not a parser. It resolves the
common docstring and comment-above-def idioms; an unusual layout falls back
to the file rather than guessing wrong.

## Testing

Unit tests with synthetic fixtures: docstring-tag → enclosing def; comment-
above-def → the def below; file-header tag → file fallback; look-ahead does not
bind a docstring tag to a later nested def; `--name` filter (found + not-found
with available list); cross-language collection (a PAYMENT flow spanning `.py`
and `.js`). Self-scan sanity: CodeLens's own tree collects its dispatch flows,
including this feature's own `FLOW_VIEW`.
9 changes: 9 additions & 0 deletions scripts/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
"module": "commands.tags",
"help": "Audit @FLOW/@ENTRY/@PART doc-tags: flow inventory, header coverage, untagged files (issue #305)",
},
"flow": {
"module": "commands.flow",
"help": "Collect a named @FLOW's scattered functions into one view (--name X; issue #309)",
},
}

ALL_CHECKS = list(_CHECKS.keys())
Expand All @@ -79,6 +83,7 @@ def add_args(parser):
" diagnostics LSP lint/errors/warnings for a file (needs --file, issue #253)\n"
" overview Token-efficient hierarchical symbols map (issue #254)\n"
" tags Audit @FLOW/@ENTRY/@PART doc-tags (issue #305)\n"
" flow Collect a named @FLOW's scattered functions (--name X, issue #309)\n"
"\n"
"Examples:\n"
" codelens context . # orient (default)\n"
Expand All @@ -89,6 +94,8 @@ def add_args(parser):
" codelens context . --check overview # workspace symbol map\n"
" codelens context . --check overview --file src/auth.ts\n"
" codelens context . --check tags # doc-tag audit\n"
" codelens context . --check flow # list all named flows\n"
" codelens context . --check flow --name PAYMENT # collect one flow\n"
)
parser.add_argument("workspace", nargs="?", default=None,
help="Path to workspace root (auto-detected if omitted)")
Expand Down Expand Up @@ -178,6 +185,8 @@ def _build_namespace(base_args, check_name: str) -> argparse.Namespace:
elif check_name == "overview":
ns.file = getattr(base_args, "file", None)
ns.max_files = getattr(base_args, "max_files", None) or 200
elif check_name == "flow":
ns.name = getattr(base_args, "name", None)
return ns


Expand Down
79 changes: 79 additions & 0 deletions scripts/commands/flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# @WHO: scripts/commands/flow.py
# @WHAT: `context --check flow` — collect a named flow's scattered functions
# @PART: command (sub-check of `context`)
# @ENTRY: execute()
"""`context --check flow [--name X]` — the named-flow view.

Agents author `@FLOW: NAME` tags in the source; this reads them back and
collects every function carrying a given flow name into one view, so a chain
that is scattered across files reads as a single list. Pure read-only — it
never invents a tag, it only serves what an agent wrote (the tags are the
source of truth; CodeLens owns the graph and the query).

Without ``--name`` it lists every named flow and its size. The tag parsing
lives in ``tag_audit_engine``; this only reshapes it flow-first.
"""

from typing import Any, Dict

from tag_audit_engine import audit_tags


def add_args(parser):
"""Register CLI arguments (workspace + name are carried by the umbrella)."""
parser.add_argument(
"workspace", nargs="?", default=None,
help="Path to workspace root (auto-detected if omitted)",
)


def execute(args, workspace) -> Dict[str, Any]:
"""Collect the named flow(s) for ``workspace``.

@FLOW: FLOW_VIEW
@CALLS: tag_audit_engine.audit_tags() -> dict
@MUTATES: nothing (read-only)
"""
audit = audit_tags(workspace)
if audit.get("status") == "error":
return audit

flows = audit.get("flows", [])
name = getattr(args, "name", None)

if not name:
# Inventory: every flow and its member count.
return {
"status": "ok",
"workspace": workspace,
"flows": [
{"name": f["name"], "count": f["count"], "members": f["members"]}
for f in flows
],
"summary": {"distinct_flows": len(flows)},
}

# A single named flow: its scattered members, collected.
match = next((f for f in flows if f["name"] == name), None)
if match is None:
available = [f["name"] for f in flows]
return {
"status": "ok",
"workspace": workspace,
"flow": name,
"found": False,
"members": [],
"count": 0,
"available_flows": available,
"message": f"No @FLOW: {name} tag found. Known flows: "
+ (", ".join(available) if available else "(none)"),
}

return {
"status": "ok",
"workspace": workspace,
"flow": name,
"found": True,
"count": match["count"],
"members": match["members"],
}
2 changes: 1 addition & 1 deletion scripts/formatters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def _normalize_to_ai(data: Any, command: str = "") -> Dict[str, Any]:
_ITEM_KEYS = [
"functions", "findings", "leaks", "hints", "issues",
"matches", "violations", "entrypoints", "routes", "stores",
"results", "ownership_summary", "chains", "flows",
"results", "ownership_summary", "chains", "flows", "members",
"by_category", "top_priority", "actionable_items",
]

Expand Down
34 changes: 34 additions & 0 deletions scripts/formatters/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,47 @@ def to_markdown(data: Any, command: str = "") -> str:
_md_analyze(data, lines)
elif command == "tags":
_md_tags(data, lines)
elif command == "flow":
_md_flow(data, lines)
else:
# Generic markdown for any command
_md_generic(data, lines)

return "\n".join(lines)


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:
symbol = m.get("symbol") or "(file)"
return f"- `{symbol}` — {m.get('file', '')}:{m.get('line', '')}"

if "flow" in data:
# Single named flow.
name = data.get("flow", "")
if not data.get("found"):
lines.append(f"## Flow: `{name}` — not found")
lines.append("")
lines.append(data.get("message", ""))
return
n = data.get("count", 0)
lines.append(f"## Flow: `{name}` ({n} function{'' if n == 1 else 's'})")
lines.append("")
for m in data.get("members", []):
lines.append(_member_line(m))
return

# Inventory of all flows.
flows = data.get("flows", [])
lines.append(f"## Named Flows ({len(flows)})")
lines.append("")
for f in flows:
lines.append(f"### `{f.get('name', '')}` ({f.get('count', 0)})")
for m in f.get("members", []):
lines.append(_member_line(m))
lines.append("")


def _md_tags(data: Dict, lines: list) -> None:
"""Markdown for the doc-tag audit (`context --check tags`, issue #305)."""
s = data.get("summary", {})
Expand Down
Loading
Loading