From ca0e56464943b26b75dfa03c2f25901434ab8579 Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Fri, 17 Jul 2026 22:11:16 +0700 Subject: [PATCH] =?UTF-8?q?feat(context):=20add=20`tags`=20sub-check=20?= =?UTF-8?q?=E2=80=94=20audit=20the=20@FLOW/@ENTRY=20doc-tag=20convention?= =?UTF-8?q?=20(closes=20#305)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @WHO/@WHAT/@PART/@ENTRY + @FLOW/@CALLS/@MUTATES convention is used across the codebase (46/236 .py files carry the header, 21 named flows) but nothing read it back, so the data rotted: two flows (LLM_INVOKE, LLM_TOOL_INVOKE) still name code whose command was dropped in #195. `context --check tags` answers three questions from the tags already in the source, inventing nothing: flow inventory + locations, header coverage (full/partial/none), and the untagged-file list. Pure regex, no LLM, no network, all collections sorted — deterministic. A tag counts only when it opens a comment/docstring line (marker + whitespace + @TAG:), so a prose mention like `the `@FLOW: PURE` example` is not mistaken for a declaration — without that anchor, any file documenting the convention (this engine included) registered phantom flows. Caught by dogfooding. Read-only by design: never writes tags back. Auto-tagging and staleness-by-body-hash are out of scope (deciding a tag's value is authorship, which for a no-LLM tool belongs to the human/agent). Reuses BaseEngine for the walk — no new walker. Command count stays 12 (sub-check, not a top-level command). Registry allowlist, docs sync (#278), and command-count gates all green; full suite 19 failures = 19 on main, identical list. Known limitation: markdown/ai formats render the context umbrella empty (pre-existing, all sub-checks) — tracked in #306. json/compact correct. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- SKILL-QUICK.md | 2 +- SKILL.md | 2 +- docs/design/0305-tag-audit.md | 84 ++++++++++++++++++ scripts/commands/context.py | 6 ++ scripts/commands/tags.py | 32 +++++++ scripts/tag_audit_engine.py | 152 ++++++++++++++++++++++++++++++++ tests/test_command_registry.py | 2 +- tests/test_tag_audit.py | 156 +++++++++++++++++++++++++++++++++ 9 files changed, 434 insertions(+), 4 deletions(-) create mode 100644 docs/design/0305-tag-audit.md create mode 100644 scripts/commands/tags.py create mode 100644 scripts/tag_audit_engine.py create mode 100644 tests/test_tag_audit.py diff --git a/README.md b/README.md index aa7e312..c0d5e10 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 | Orientation, file structure, call-chain tracing, rich symbol context, LSP diagnostics (`--file`), token-efficient symbol map. | +| `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. | | `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 bdb8db6..f926542 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) | +| `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) | | `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 1dd0159..cc9296c 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) | +| `context` | orient (default) · outline · trace · context · diagnostics (LSP lint, needs `--file`) · overview (token-efficient symbol map) · tags (`@FLOW`/`@ENTRY` doc-tag audit) | | `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/0305-tag-audit.md b/docs/design/0305-tag-audit.md new file mode 100644 index 0000000..e8eff77 --- /dev/null +++ b/docs/design/0305-tag-audit.md @@ -0,0 +1,84 @@ +# Design Doc: Doc-Tag Audit (`context --check tags`) + +> **Status:** Accepted +> **Date:** 2026-07-17 +> **Author:** Wolfvin (BOS) / Claude Code +> **Related issues:** #305 +> **Related PRs:** (this PR) + +--- + +## Problem + +CodeLens (and every Wolfvin project) documents intent with a per-file header +(`@WHO`/`@WHAT`/`@PART`/`@ENTRY`) and per-function docstring tags +(`@FLOW`/`@CALLS`/`@MUTATES`). The convention is real and enforced by habit, +but **nothing reads it back**, so the data rots silently: + +- 46 of 236 `.py` files in `scripts/` carry the header (19%). The other 190 are + invisible to any "which files are undocumented?" question. +- 21 named flows are hand-written (`AUDIT_DISPATCH`, `SECRETS_SCAN`, `ORIENT`…), + but there is no way to list them or ask "which flow does this file belong to?" +- Two flows — `LLM_INVOKE` (`scripts/llm/provider.py:447`) and `LLM_TOOL_INVOKE` + (`scripts/llm/base_tool.py:241`) — name code whose command (`llm`) was dropped + in #195. Orphaned tags accumulate with nothing to surface them. + +A convention with no tooling is documentation that lies over time. + +## Goal + +One sub-check that answers three questions from the tags **already in the +source**, inventing nothing: what flows exist and where, which files carry a +full / partial / no header, and which files are untagged. Fully deterministic +(regex only, no LLM), so two runs on the same tree are byte-identical. + +## Changes + +### New Files +- `scripts/tag_audit_engine.py` — `TagAuditEngine(BaseEngine)` + `audit_tags()`. + Walks the workspace via the shared `BaseEngine` (reusing its ignore-dir and + time-budget logic — no new walker), regex-matches the tag convention, and + aggregates into a report. +- `scripts/commands/tags.py` — thin `execute()` wrapper delegating to the engine. + +### Registry +- `commands/context.py`: register `tags` in `_CHECKS` + epilog. Command count + stays 12 (sub-check, not a top-level command). + +### Identity / parsing decisions +- A tag is counted only when it **opens** a comment/docstring line (optional + `#`/`//`/`*`/`--` marker, then whitespace, then `@TAG:`). This is what + separates a real declaration from a prose mention like ``the `@FLOW: PURE` + example`` — without the anchor, any file *documenting* the convention (this + engine included) registers phantom flows. Caught by dogfooding during + implementation. +- A flow's name is the first whitespace-delimited token of its `@FLOW` value; + the remainder is human prose. +- File header completeness = presence of all four of `@WHO/@WHAT/@PART/@ENTRY`. + Partial (1–3 present) is reported separately as a likely-forgotten tag. +- Language-agnostic: the tags are identical across Python (`#`) and TS/JS + (`//`); only the comment marker differs, so detection keys on the tag, not + the language. + +## Non-goals (explicit) + +- **No writing.** The engine never adds or updates a tag in source. Auto-tagging + and staleness-by-body-hash are deliberately out of scope: deciding a tag's + *value* is authorship, which for a deterministic no-LLM tool belongs to the + human/agent, not the scanner. Tracked as a possible follow-up. +- **No reachability claim.** Orphan flows (e.g. `LLM_*`) are surfaced by + location, not asserted as dead — reachability needs more than a regex. + +## Testing + +Unit tests with synthetic fixtures (full / partial / no header, prose-mention +rejection, flow-name tokenization) plus a self-scan sanity check against +CodeLens's own tree (22 flows including this feature's own `TAG_AUDIT`, the two +`LLM_*` orphans surfaced). Deterministic output asserted by sorting. + +## Known limitation + +`--format markdown` and `--format ai` render the `context` umbrella envelope +`{s,st,r}` empty ("Symbol not found") — a pre-existing formatter gap affecting +all context sub-checks, tracked in #306. `json` and `compact` (the agent-facing +formats) are correct. diff --git a/scripts/commands/context.py b/scripts/commands/context.py index 4a8c27c..07b9068 100644 --- a/scripts/commands/context.py +++ b/scripts/commands/context.py @@ -58,6 +58,10 @@ "module": "commands.symbols_overview", "help": "Token-efficient hierarchical symbols map from registry (issue #254)", }, + "tags": { + "module": "commands.tags", + "help": "Audit @FLOW/@ENTRY/@PART doc-tags: flow inventory, header coverage, untagged files (issue #305)", + }, } ALL_CHECKS = list(_CHECKS.keys()) @@ -74,6 +78,7 @@ def add_args(parser): " orient 10-second codebase orientation brief\n" " 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" "\n" "Examples:\n" " codelens context . # orient (default)\n" @@ -83,6 +88,7 @@ def add_args(parser): " codelens context . --check diagnostics --file src/app.ts\n" " 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" ) parser.add_argument("workspace", nargs="?", default=None, help="Path to workspace root (auto-detected if omitted)") diff --git a/scripts/commands/tags.py b/scripts/commands/tags.py new file mode 100644 index 0000000..d41e7e4 --- /dev/null +++ b/scripts/commands/tags.py @@ -0,0 +1,32 @@ +# @WHO: scripts/commands/tags.py +# @WHAT: Thin CLI wrapper for the doc-tag audit — delegates to tag_audit_engine +# @PART: command (sub-check of `context`) +# @ENTRY: execute() +"""`context --check tags` — audit the @FLOW/@ENTRY/@PART doc-tag convention. + +Inventories named flows, measures header coverage, and lists untagged / +partially-tagged files, all from the tags already present in the source. Pure +read-only scan (issue #305). The real work lives in ``tag_audit_engine``. +""" + +from typing import Any, Dict + +from tag_audit_engine import audit_tags + + +def add_args(parser): + """Register CLI arguments (workspace is 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]: + """Run the tag audit for ``workspace``. + + @FLOW: TAG_AUDIT + @CALLS: tag_audit_engine.audit_tags() -> dict + @MUTATES: nothing (read-only) + """ + return audit_tags(workspace) diff --git a/scripts/tag_audit_engine.py b/scripts/tag_audit_engine.py new file mode 100644 index 0000000..e92d7c7 --- /dev/null +++ b/scripts/tag_audit_engine.py @@ -0,0 +1,152 @@ +# @WHO: scripts/tag_audit_engine.py +# @WHAT: Audit the @WHO/@WHAT/@PART/@ENTRY + @FLOW/@CALLS/@MUTATES doc-tag +# convention: inventory named flows, measure header coverage, list +# untagged and partially-tagged files (issue #305) +# @PART: engine +# @ENTRY: TagAuditEngine, audit_tags() +"""Tag-convention audit for CodeLens. + +The codebase documents intent with a header block per file +(``@WHO``/``@WHAT``/``@PART``/``@ENTRY``) and per-function tags in docstrings +(``@FLOW``/``@CALLS``/``@MUTATES``). The convention spans languages — the tags +appear in ``#`` comments (Python) and ``//`` comments (TS/JS) alike — but until +now nothing read them back, so the data rotted silently (e.g. flows naming +code that a command consolidation later dropped). + +This engine answers three questions from the tags *already in the code*, +inventing nothing: + +1. **Inventory** — every distinct ``@FLOW`` name and where it is declared. +2. **Coverage** — which files carry a full header, a partial one, or none. +3. **Partial headers** — files missing some (not all) of the four header tags, + which usually means a tag was forgotten. + +Pure and deterministic: regex only, no LLM, no network, every collection +sorted so two runs on the same tree are byte-identical. + +@FLOW: TAG_AUDIT +@CALLS: TagAuditEngine.run() -> dict (walks workspace via BaseEngine) +@MUTATES: none (read-only scan; never writes tags back to source) +""" + +from __future__ import annotations + +import re +from typing import Any, Dict, List, Set + +from base_engine import BaseEngine + +# The four file-header tags, in canonical order. +HEADER_TAGS = ("WHO", "WHAT", "PART", "ENTRY") +# Per-function tags. +FUNCTION_TAGS = ("FLOW", "CALLS", "MUTATES") + +# Match `@TAG: value` only when it OPENS a comment/docstring line — optional +# comment marker (`#`, `//`, `*`, `--`) then whitespace then the tag. Anchoring +# to line start is what separates a real declaration from a prose mention like +# `... the `@FLOW: PURE` example ...`, which would otherwise pollute the +# inventory (any file *documenting* the convention, this engine included, would +# register phantom flows). The value runs to end of line. +_TAG_RE = re.compile( + r"^[ \t]*(?:#|//|\*|--|/\*)?[ \t]*" + r"@(" + "|".join(HEADER_TAGS + FUNCTION_TAGS) + r"):[ \t]*(.*)" +) + +# Source extensions that carry the convention. Language-agnostic: the tags are +# the same across all of them; only the comment marker differs. +_SOURCE_EXTENSIONS: Set[str] = { + ".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", + ".go", ".rs", ".java", ".php", ".rb", ".c", ".cc", ".cpp", ".h", ".hpp", + ".cs", ".kt", ".swift", ".css", ".scss", ".html", ".vue", ".svelte", +} + +# Cap on enumerated file lists; counts stay exact. +_LIST_CAP = 200 + + +def _flow_name(raw: str) -> str: + """The flow name is the first whitespace-delimited token of an @FLOW value. + + `@FLOW: PURE (declarations only)` -> `PURE`; `@FLOW: AUDIT_DISPATCH` -> + `AUDIT_DISPATCH`. The remainder is human prose, not part of the identity. + """ + return raw.strip().split(None, 1)[0] if raw.strip() else "" + + +class TagAuditEngine(BaseEngine): + """Scan source files for the doc-tag convention and report on it.""" + + FILE_EXTENSIONS = _SOURCE_EXTENSIONS + + def _pre_scan(self, workspace: str, **kwargs) -> None: + # flow name -> sorted set of "rel_path:line" declaration sites + self._flows: Dict[str, List[str]] = {} + self._full_header: List[str] = [] + self._partial_header: List[Dict[str, Any]] = [] + self._untagged: List[str] = [] + + 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("\\", "/") + + for lineno, line in enumerate(content.splitlines(), start=1): + m = _TAG_RE.search(line) + if not m: + continue + tag, value = m.group(1), m.group(2) + if tag in HEADER_TAGS: + header_present.add(tag) + elif tag == "FLOW": + name = _flow_name(value) + if name: + self._flows.setdefault(name, []).append(f"{rel}:{lineno}") + + if not header_present: + self._untagged.append(rel) + elif header_present.issuperset(HEADER_TAGS): + self._full_header.append(rel) + else: + self._partial_header.append({ + "file": rel, + "present": sorted(header_present), + "missing": [t for t in HEADER_TAGS if t not in header_present], + }) + + # This engine aggregates in instance state, not per-file findings. + 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()) + ] + partial = sorted(self._partial_header, key=lambda d: d["file"]) + untagged = sorted(self._untagged) + scanned = self._files_scanned + + tagged = len(self._full_header) + len(partial) + return { + "status": "ok", + "workspace": workspace, + "check": "tags", + "summary": { + "files_scanned": scanned, + "with_full_header": len(self._full_header), + "with_partial_header": len(partial), + "without_header": len(untagged), + "header_coverage_pct": round(100 * tagged / scanned, 1) if scanned else 0.0, + "distinct_flows": len(flows), + "total_flow_declarations": sum(f["count"] for f in flows), + }, + "flows": flows, + "partial_headers": partial[:_LIST_CAP], + "partial_headers_truncated": len(partial) > _LIST_CAP, + "untagged_files": untagged[:_LIST_CAP], + "untagged_files_truncated": len(untagged) > _LIST_CAP, + } + + +def audit_tags(workspace: str) -> Dict[str, Any]: + """Run the tag audit against ``workspace`` and return the report dict.""" + return TagAuditEngine().run(workspace) diff --git a/tests/test_command_registry.py b/tests/test_command_registry.py index 3fdef30..36ac2e9 100644 --- a/tests/test_command_registry.py +++ b/tests/test_command_registry.py @@ -45,7 +45,7 @@ def test_every_command_module_registers(): "export_snapshot", "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", "taint", "trace", "vuln_scan", + "staleness", "symbols_overview", "tags", "taint", "trace", "vuln_scan", } _UTILITY_MODULES |= _DEPRECATED_ALIAS_MODULES missing = [] diff --git a/tests/test_tag_audit.py b/tests/test_tag_audit.py new file mode 100644 index 0000000..2a3dd19 --- /dev/null +++ b/tests/test_tag_audit.py @@ -0,0 +1,156 @@ +""" +Tests for the doc-tag audit engine (issue #305). + +Covers, against a synthetic fixture tree (not just a self-scan): +- flow inventory: distinct names, locations, first-token naming +- header coverage: full / partial / none classification +- prose-mention rejection: `@FLOW:` inside backticks must not count +- determinism: sorted output +- read-only: the scan never writes to source +""" + +from __future__ import annotations + +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, _flow_name # 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) + # Full header + a named flow (Python). + _write(root, "full.py", + "# @WHO: full.py\n" + "# @WHAT: a thing\n" + "# @PART: engine\n" + "# @ENTRY: run()\n" + "def run():\n" + " '''Charge the cart.\n" + "\n" + " @FLOW: PAYMENT_FLOW\n" + " @CALLS: charge()\n" + " @MUTATES: nothing\n" + " '''\n") + # Partial header — missing PART and ENTRY. + _write(root, "partial.py", + "# @WHO: partial.py\n" + "# @WHAT: half-documented\n" + "x = 1\n") + # No tags at all. + _write(root, "bare.py", "y = 2\n") + # TS file, full header + flow via // comments (language-agnostic). + _write(root, "web/app.ts", + "// @WHO: app.ts\n" + "// @WHAT: ui\n" + "// @PART: frontend\n" + "// @ENTRY: main()\n" + "// @FLOW: PAYMENT_FLOW\n" + "export function main() {}\n") + # A file that DOCUMENTS the convention in prose — must NOT register flows. + _write(root, "docs_mention.py", + '"""Explains the tags.\n' + "The `@FLOW: GHOST_FLOW` marker names a chain; `@WHO` heads a file.\n" + '"""\n') + return root + + +def test_flow_inventory_lists_distinct_names(tree): + result = audit_tags(tree) + names = [f["name"] for f in result["flows"]] + + assert names == ["PAYMENT_FLOW"] # sorted, distinct, prose GHOST_FLOW excluded + + +def test_flow_records_all_declaration_sites(tree): + result = audit_tags(tree) + payment = next(f for f in result["flows"] if f["name"] == "PAYMENT_FLOW") + + assert payment["count"] == 2 # full.py + web/app.ts + assert payment["locations"] == sorted(payment["locations"]) + assert any(loc.startswith("full.py:") for loc in payment["locations"]) + assert any(loc.startswith("web/app.ts:") for loc in payment["locations"]) + + +def test_prose_mention_is_not_counted_as_a_flow(tree): + result = audit_tags(tree) + names = [f["name"] for f in result["flows"]] + + assert "GHOST_FLOW" not in names + + +def test_prose_mention_file_counts_as_untagged(tree): + """docs_mention.py names @WHO/@FLOW only in prose → no real header.""" + result = audit_tags(tree) + + assert "docs_mention.py" in result["untagged_files"] + + +def test_header_classification(tree): + result = audit_tags(tree) + s = result["summary"] + + assert s["with_full_header"] == 2 # full.py, web/app.ts + assert s["with_partial_header"] == 1 # partial.py + assert s["without_header"] == 2 # bare.py, docs_mention.py + + +def test_partial_header_reports_missing_tags(tree): + result = audit_tags(tree) + partial = next(p for p in result["partial_headers"] if p["file"] == "partial.py") + + assert partial["present"] == ["WHAT", "WHO"] + assert partial["missing"] == ["PART", "ENTRY"] + + +def test_untagged_list_is_sorted(tree): + result = audit_tags(tree) + + assert result["untagged_files"] == sorted(result["untagged_files"]) + + +def test_output_is_deterministic(tree): + assert audit_tags(tree) == audit_tags(tree) + + +def test_scan_is_read_only(tree): + before = { + rel: os.path.getmtime(os.path.join(tree, rel)) + for rel in ("full.py", "partial.py", "bare.py") + } + audit_tags(tree) + after = { + rel: os.path.getmtime(os.path.join(tree, rel)) + for rel in ("full.py", "partial.py", "bare.py") + } + + assert before == after + + +# ─── _flow_name unit ───────────────────────────────────── + +@pytest.mark.parametrize("raw,expected", [ + ("PAYMENT_FLOW", "PAYMENT_FLOW"), + ("PURE (declarations only)", "PURE"), + (" AUDIT_DISPATCH ", "AUDIT_DISPATCH"), + ("", ""), +]) +def test_flow_name_takes_first_token(raw, expected): + assert _flow_name(raw) == expected