diff --git a/README.md b/README.md index c0d5e10..9e9304f 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/SKILL-QUICK.md b/SKILL-QUICK.md index f926542..21aba54 100755 --- a/SKILL-QUICK.md +++ b/SKILL-QUICK.md @@ -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 | diff --git a/SKILL.md b/SKILL.md index cc9296c..98b7bea 100755 --- a/SKILL.md +++ b/SKILL.md @@ -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) | diff --git a/docs/design/0309-named-flow.md b/docs/design/0309-named-flow.md new file mode 100644 index 0000000..8751f1b --- /dev/null +++ b/docs/design/0309-named-flow.md @@ -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`. diff --git a/scripts/commands/context.py b/scripts/commands/context.py index 07b9068..8e12886 100644 --- a/scripts/commands/context.py +++ b/scripts/commands/context.py @@ -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()) @@ -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" @@ -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)") @@ -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 diff --git a/scripts/commands/flow.py b/scripts/commands/flow.py new file mode 100644 index 0000000..e4f7a30 --- /dev/null +++ b/scripts/commands/flow.py @@ -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"], + } diff --git a/scripts/formatters/__init__.py b/scripts/formatters/__init__.py index a9d4366..185089d 100644 --- a/scripts/formatters/__init__.py +++ b/scripts/formatters/__init__.py @@ -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", ] diff --git a/scripts/formatters/markdown.py b/scripts/formatters/markdown.py index f7769c6..7b97e68 100644 --- a/scripts/formatters/markdown.py +++ b/scripts/formatters/markdown.py @@ -141,6 +141,8 @@ 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) @@ -148,6 +150,38 @@ def to_markdown(data: Any, command: str = "") -> str: 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", {}) diff --git a/scripts/tag_audit_engine.py b/scripts/tag_audit_engine.py index e92d7c7..95ec71b 100644 --- a/scripts/tag_audit_engine.py +++ b/scripts/tag_audit_engine.py @@ -63,6 +63,54 @@ # Cap on enumerated file lists; counts stay exact. _LIST_CAP = 200 +# Broad, language-agnostic declaration pattern — captures the symbol name from +# a keyword-style definition (py/rust/go/ts/java/php/c...) or a JS-style +# `const foo =` / `foo(...) {`. Used only to attribute a @FLOW tag to the +# nearest enclosing symbol; a miss falls back to the file, so it never has to +# be exhaustive. +_DEF_RE = re.compile( + r"^[ \t]*(?:export\s+|public\s+|private\s+|protected\s+|static\s+|async\s+|" + r"final\s+|pub\s+)*" + r"(?:def|fn|func|function|class|struct|interface|type|impl|trait)\s+(\w+)" +) +_JS_DEF_RE = re.compile( + r"^[ \t]*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=|" + r"^[ \t]*(\w+)\s*\([^)]*\)\s*(?:\{|=>|:)" +) + + +def _symbol_at(line: str) -> str: + """Return the symbol declared on ``line``, or '' if it declares none.""" + m = _DEF_RE.match(line) + if m: + return m.group(1) + m = _JS_DEF_RE.match(line) + if m: + return m.group(1) or m.group(2) or "" + return "" + + +# How many lines below a tag to scan for a `comment-above-def` declaration. +_LOOKAHEAD = 3 + + +def _lookahead_symbol(lines, idx: int) -> str: + """Symbol declared just below line ``idx`` (a comment-above-def), or ''. + + Only contiguous comment lines may sit between the tag and the declaration. + A blank line ends the run: it separates a file-header tag (blank, then an + unrelated first declaration) from a genuine comment-above-def, and a + docstring tag (whose following lines are code) never reaches a def. + """ + for j in range(idx + 1, min(idx + 1 + _LOOKAHEAD, len(lines))): + stripped = lines[j].strip() + sym = _symbol_at(lines[j]) + if sym: + return sym + if not stripped or not stripped.startswith(("//", "#", "*", "/*", '"""', "'''")): + break # blank line or real code before a def — not comment-above-def + return "" + def _flow_name(raw: str) -> str: """The flow name is the first whitespace-delimited token of an @FLOW value. @@ -89,8 +137,15 @@ def _analyze_file(self, rel_path, content, ext, abs_path) -> List[Dict[str, Any] header_present: Set[str] = set() # Normalise separators so keys match across OSes (Windows scan, Linux CI). rel = rel_path.replace("\\", "/") + source_lines = content.splitlines() + last_symbol = "" # nearest declaration seen while scanning down the file + + for idx, line in enumerate(source_lines): + lineno = idx + 1 + symbol = _symbol_at(line) + if symbol: + last_symbol = symbol - for lineno, line in enumerate(content.splitlines(), start=1): m = _TAG_RE.search(line) if not m: continue @@ -100,7 +155,16 @@ def _analyze_file(self, rel_path, content, ext, abs_path) -> List[Dict[str, Any] elif tag == "FLOW": name = _flow_name(value) if name: - self._flows.setdefault(name, []).append(f"{rel}:{lineno}") + # Attribute the flow to a function. A comment-above-def + # (`// @FLOW` then the declaration) is the most specific, so + # look a few lines down first; otherwise fall back to the + # enclosing def seen above (docstring case); a tag with + # neither (file header) attributes to the file itself. + self._flows.setdefault(name, []).append({ + "symbol": _lookahead_symbol(source_lines, idx) or last_symbol, + "file": rel, + "line": lineno, + }) if not header_present: self._untagged.append(rel) @@ -117,10 +181,17 @@ def _analyze_file(self, rel_path, content, ext, abs_path) -> List[Dict[str, Any] return [] def _build_result(self, workspace: str) -> Dict[str, Any]: - flows = [ - {"name": name, "count": len(locs), "locations": sorted(locs)} - for name, locs in sorted(self._flows.items()) - ] + flows = [] + for name, members in sorted(self._flows.items()): + ordered = sorted(members, key=lambda m: (m["file"], m["line"])) + flows.append({ + "name": name, + "count": len(ordered), + # Members carry the enclosing symbol; locations stays for + # backward compat (tag-audit consumers + ai item extraction). + "members": ordered, + "locations": [f"{m['file']}:{m['line']}" for m in ordered], + }) partial = sorted(self._partial_header, key=lambda d: d["file"]) untagged = sorted(self._untagged) scanned = self._files_scanned diff --git a/tests/test_command_registry.py b/tests/test_command_registry.py index 36ac2e9..9fe7db2 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", "git_status", "graph_schema", "import_snapshot", + "export_snapshot", "flow", "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_named_flow.py b/tests/test_named_flow.py new file mode 100644 index 0000000..d7af060 --- /dev/null +++ b/tests/test_named_flow.py @@ -0,0 +1,138 @@ +""" +Tests for the named-flow view (`context --check flow`, issue #309). + +Covers, against a synthetic fixture tree: +- enclosing-symbol resolution: docstring idiom, comment-above-def idiom, + file-header fallback, and look-ahead not binding across real code. +- cross-language collection of one flow. +- the `flow` command: --name filter (found + not-found), bare inventory. +""" + +from __future__ import annotations + +import argparse +import os +import sys + +import pytest + +SCRIPTS_DIR = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "scripts") +) +if SCRIPTS_DIR not in sys.path: + sys.path.insert(0, SCRIPTS_DIR) + +from tag_audit_engine import audit_tags # noqa: E402 +from commands import flow as flow_cmd # noqa: E402 + + +def _write(root, rel, text): + path = os.path.join(root, rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(text) + return path + + +@pytest.fixture +def tree(tmp_path): + root = str(tmp_path) + # Docstring idiom: tag inside the function body -> enclosing def above. + _write(root, "routes.py", + 'def checkout_route(req):\n' + ' """Entry.\n' + ' @FLOW: PAYMENT\n' + ' """\n' + ' return validate(req)\n') + # Comment-above-def idiom (JS): tag one line above the declaration. + _write(root, "gateway.js", + '// @FLOW: PAYMENT\n' + 'export function charge(amount) {\n' + ' return stripe(amount);\n' + '}\n') + # File-header idiom: tag at the very top, no def near -> file fallback. + _write(root, "types.d.ts", + '// @WHO: types.d.ts\n' + '// @FLOW: PURE (declarations only)\n' + '\n' + 'export type Money = number;\n') + return root + + +def _flows(root): + return {f["name"]: f for f in audit_tags(root)["flows"]} + + +# ─── enclosing-symbol resolution ───────────────────────── + +def test_docstring_tag_binds_to_enclosing_function(tree): + members = {(m["file"], m["symbol"]) for m in _flows(tree)["PAYMENT"]["members"]} + assert ("routes.py", "checkout_route") in members + + +def test_comment_above_def_binds_to_def_below(tree): + members = {(m["file"], m["symbol"]) for m in _flows(tree)["PAYMENT"]["members"]} + assert ("gateway.js", "charge") in members + + +def test_file_header_tag_falls_back_to_file(tree): + pure = _flows(tree)["PURE"]["members"] + assert len(pure) == 1 + assert pure[0]["symbol"] == "" # no enclosing symbol -> file-level + assert pure[0]["file"] == "types.d.ts" + + +def test_lookahead_does_not_cross_real_code(tmp_path): + """A docstring tag must not bind to a nested def a few lines down.""" + root = str(tmp_path) + _write(root, "a.py", + 'def outer():\n' + ' """\n' + ' @FLOW: OUTER\n' + ' """\n' + ' x = 1\n' + ' def inner():\n' + ' pass\n') + members = _flows(root)["OUTER"]["members"] + assert members[0]["symbol"] == "outer" # not "inner" + + +def test_flow_collected_across_languages(tree): + payment = _flows(tree)["PAYMENT"] + assert payment["count"] == 2 + files = {m["file"] for m in payment["members"]} + assert files == {"routes.py", "gateway.js"} + + +# ─── the flow command ──────────────────────────────────── + +def _run(root, name=None): + args = argparse.Namespace(name=name) + return flow_cmd.execute(args, root) + + +def test_named_flow_returns_only_that_flow(tree): + out = _run(tree, "PAYMENT") + assert out["found"] is True + assert out["flow"] == "PAYMENT" + assert out["count"] == 2 + assert {m["symbol"] for m in out["members"]} == {"checkout_route", "charge"} + + +def test_unknown_flow_is_not_found_with_available_list(tree): + out = _run(tree, "NOPE") + assert out["found"] is False + assert out["count"] == 0 + assert "PAYMENT" in out["available_flows"] + + +def test_bare_flow_lists_every_flow(tree): + out = _run(tree) + names = {f["name"] for f in out["flows"]} + assert {"PAYMENT", "PURE"} <= names + assert out["summary"]["distinct_flows"] == len(out["flows"]) + + +if __name__ == "__main__": + import pytest as _p + _p.main([__file__, "-v"]) diff --git a/tests/test_umbrella_formats.py b/tests/test_umbrella_formats.py index d87ef33..2501071 100644 --- a/tests/test_umbrella_formats.py +++ b/tests/test_umbrella_formats.py @@ -95,6 +95,21 @@ def test_envelope_records_check_metadata(self): self.assertIn("checks", out["metadata"]) + def test_named_flow_members_become_items(self): + """A single flow's members must surface as ai items (issue #309).""" + flow_sub = { + "status": "ok", "_check": "flow", "flow": "PAYMENT", "found": True, + "count": 2, + "members": [ + {"symbol": "charge", "file": "gw.js", "line": 2}, + {"symbol": "validate", "file": "cart.py", "line": 1}, + ], + } + out = _normalize_to_ai(_envelope(flow_sub), "context") + + self.assertEqual(len(out["items"]), 2) + self.assertEqual({i["symbol"] for i in out["items"]}, {"charge", "validate"}) + if __name__ == "__main__": unittest.main()