diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2588ed4..4494c50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,17 @@ Every change a user would notice, newest first ([Keep a Changelog](https://keepa
follow [Semantic Versioning](https://semver.org/); before 1.0, a minor version may change what a command does.
## [Unreleased]
+### Added
+
+- A Decisions tab beside the graph draws each call as a flowchart, top to bottom: each `if` as a question with an
+ arrow for yes and for no, `elif` and `match` as the next question, loops as boxes with an arrow back to their top,
+ `try` with the way taken when a line in it raises, and where the call returns or raises. The usual path runs down
+ the middle, and an answer that ends early (returns, raises, skips to the next item, leaves the loop) sits beside it.
+ A call into the code's own functions that decides things too is opened in place, or, deeper down, gets a flow of
+ its own its box links to. A web app has a flow for each route. It needs no JavaScript.
+- Step by step says when a branch ends in `continue` or `break`, and an `except` that only continues is no longer
+ left out.
+
## [0.9.0] - 2026-10-22
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 418aacc..ba58d83 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,7 +54,7 @@ Slash commands in `.claude/commands/`: `/check` (lint, tests, docs, package), `/
| `src/code_node/pages.py` | `PageOptions`, `make_page` / `make_mermaid`: read the folder, map, draw (what the command and the server share) |
| `src/code_node/history.py` | What git says about each function shown (`read_history`: `git blame` once per file, onto `CodeFunction.history`). Read-only git, with no file system monitor or text conversion, and none at all when the folder's own config names a filter command |
| `src/code_node/serve.py` | `--serve`: `LivePage` (the latest page, a version, a Condition), a polling watcher thread, `ThreadingHTTPServer` with `/` and `/events` (Server-Sent Events) |
-| `src/code_node/report/` | Drawing: `brands.py` (which logo each AWS service and library service gets, read from `logos/*.svg` and, for AWS's, a small mark in `logos/small/` for 16 to 32 px; `logo_for(name, pixels)` picks, and the page's graph has both and picks by screen density. Written by `scripts/logos.py`: never edit them), `graph.py` (the SVG layout), `walk.py` (Step by step: the steps in run order beside their code), `tabs.py` (the Graph / Step by step tabs), `outline.py` (the step tree, for text), `build.py` (`page_blocks`, the page), `style.py` (CSS, `render_page`) |
+| `src/code_node/report/` | Drawing: `brands.py` (which logo each AWS service and library service gets, read from `logos/*.svg` and, for AWS's, a small mark in `logos/small/` for 16 to 32 px; `logo_for(name, pixels)` picks, and the page's graph has both and picks by screen density. Written by `scripts/logos.py`: never edit them), `graph.py` (the SVG layout), `flow.py` (Decisions: each call as a flowchart of its ifs, loops and trys, top to bottom), `walk.py` (Step by step: the steps in run order beside their code), `tabs.py` (the Graph / Decisions / Step by step tabs), `outline.py` (the step tree, for text), `build.py` (`page_blocks`, the page), `style.py` (CSS, `render_page`) |
| `src/code_node/kit/` | Report blocks (`_Title`, `_Cards`, `_Table`, `_Findings`, ...) and the HTML/text renderer, copied from aws-analyzer's `_kit` |
| `examples/orders-service/` | A small Lambda service the screenshots and the CI smoke test run on. Never run |
| `examples/books-api/` | A FastAPI app on the web platform (routes on an `APIRouter`, PostgreSQL, Redis, S3), with a mistake for each web finding, for `tests/snapshots/books-api.txt` and the Plugins page's web apps. Never run |
diff --git a/README.md b/README.md
index 9fd418d..20deb7a 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ cd your/repo && code-node
| | |
| --- | --- |
| 🗺️ **Graph** | What comes in, who calls whom, what each path reaches. AWS calls coloured by kind (read, write, send, run); ↻ marks loops |
+| 🔀 **Decisions** | Each call as a flowchart: every `if` a question with a yes and a no arrow, loops, `try`, where it returns or raises |
| 👣 **Step by step** | Walk the code in run order, like a debugger: Next, Skip call, Finish, arrow keys |
| 🔌 **Plugins** | boto3, Redis, SQL (sqlite3, psycopg, PyMySQL, asyncpg, SQLAlchemy), MongoDB, OpenAI, Anthropic |
| 📄 **Deploy checks** | Reads SAM `template.yaml` / `serverless.yml`: unset variables, IAM calls the role won't allow, timeouts that don't fit |
diff --git a/docs/images/decisions-dark.webp b/docs/images/decisions-dark.webp
new file mode 100644
index 0000000..8b50990
Binary files /dev/null and b/docs/images/decisions-dark.webp differ
diff --git a/docs/images/decisions-light.webp b/docs/images/decisions-light.webp
new file mode 100644
index 0000000..970ded7
Binary files /dev/null and b/docs/images/decisions-light.webp differ
diff --git a/docs/images/page-dark.webp b/docs/images/page-dark.webp
index f06f646..4874ef3 100644
Binary files a/docs/images/page-dark.webp and b/docs/images/page-dark.webp differ
diff --git a/docs/images/page-light.webp b/docs/images/page-light.webp
index f885978..5979425 100644
Binary files a/docs/images/page-light.webp and b/docs/images/page-light.webp differ
diff --git a/docs/images/steps-dark.webp b/docs/images/steps-dark.webp
index 693f084..e919691 100644
Binary files a/docs/images/steps-dark.webp and b/docs/images/steps-dark.webp differ
diff --git a/docs/images/steps-light.webp b/docs/images/steps-light.webp
index c2a315b..4d77008 100644
Binary files a/docs/images/steps-light.webp and b/docs/images/steps-light.webp differ
diff --git a/docs/index.md b/docs/index.md
index c4d9ac1..7abc336 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -44,8 +44,9 @@ Select a highlighted line to see the path it takes, or point at a box.
{ loading=lazy }
diff --git a/docs/reading.md b/docs/reading.md
index 2bd3921..2e6eb32 100644
--- a/docs/reading.md
+++ b/docs/reading.md
@@ -1,6 +1,6 @@
# Read the map
-What each tab of the page shows. The page is two tabs, with the entry point and the folder at the right of them.
+What each tab of the page shows. The page is three tabs, with the entry point and the folder at the right of them.
{ .gist }
## The tabs { #order }
@@ -8,6 +8,7 @@ What each tab of the page shows. The page is two tabs, with the entry point and
| Tab | Tells you |
| --- | --- |
| Graph | The paths: what comes in, who calls whom, and what it reaches |
+| Decisions | Each question the code asks, and where each answer goes |
| Step by step | Every line that matters, in the order it runs |
## The graph { #graph }
@@ -80,9 +81,43 @@ and the boxes it calls are stacked in the same order, so you can read down the b
every function at once. This is the one part of the page that needs JavaScript; without it the link isn't shown,
and the Step by step tab has every call.
+## Decisions { #decisions }
+
+The second tab draws each call as a flowchart, top to bottom, so you can see what decides which way the code goes.
+{ .gist }
+
+It starts with what comes in and the entry point, then has a box for each line that does something, in the words
+Step by step uses ("Takes `bucket`, `key` from the event"). A call into one of the code's own functions is a box
+listing what it reaches, with the graph's logos.
+
+| Shape | Is |
+| --- | --- |
+| Box pointed at both ends | An `if`: the test, with **yes** and **no** on its two arrows. An `elif` is the next question on the no arrow, and so is each `case` of a `match` |
+| Dashed box with an arrow back to its top | A loop: its lines, and the arrow back for the next item ("next order") |
+| Dashed amber arrow | Out of a `try`, to what runs when a line in it raises |
+| Black pill | Where the call returns |
+| Red pill | Where it raises |
+| Dashed purple pill | `continue` or `break`: skips to the next item, or leaves the loop |
+
+The answer that goes on runs down the middle, and one that ends early (returns, raises, skips to the next item, leaves
+the loop) sits beside it, so the usual path reads as one line. `if not check(order): continue` is asked as
+`check(order)?`, with yes going on and no skipping to the next order.
+
+{ loading=lazy }
+{ loading=lazy }
+
+??? more "Calls that decide things too"
+ A call to one of the code's own functions that makes decisions of its own is opened in place: a box with its name,
+ holding its flow, whose returns go back to the caller. Past two calls deep, or when it would be very tall, it's a
+ box that says how many decisions are inside, and links to its flow under the first one. A web app has a flow for
+ each of its routes, each starting with its method and path.
+
+!!! tip "Worth knowing"
+ Point at a box, a question or an end to see the line of code it's for.
+
## Step by step { #steps }
-The tab beside the graph walks through the code in the order it runs, as a debugger steps through it.
+The third tab walks through the code in the order it runs, as a debugger steps through it.
{ .gist }
The list on the left has every line that does something, each said in a few plain words ("Takes `bucket` from the
diff --git a/scripts/shots.py b/scripts/shots.py
index 1f79e5e..cb3a59a 100644
--- a/scripts/shots.py
+++ b/scripts/shots.py
@@ -26,6 +26,7 @@
from code_node.pages import PageOptions, map_entry
from code_node.project import entry_points, read_project
from code_node.report.build import page_blocks
+from code_node.report.flow import _Flow
from code_node.report.graph import _Graph
from code_node.report.style import render_page
from code_node.report.tabs import _Tabs
@@ -53,6 +54,7 @@ def _inside(blocks: list[Any]) -> list[Any]:
FIGURES: dict[str, Callable[[list[Any]], list[Any]]] = {
"page": lambda blocks: blocks,
"graph": lambda blocks: [b for b in _inside(blocks) if isinstance(b, _Graph)],
+ "decisions": lambda blocks: [b for b in _inside(blocks) if isinstance(b, _Flow)],
"steps": lambda blocks: [b for b in _inside(blocks) if isinstance(b, _Walk)],
"plugins": lambda blocks: [b for b in _inside(blocks) if isinstance(b, _Graph)], # drawn from the cart example
}
diff --git a/src/code_node/analysis/model.py b/src/code_node/analysis/model.py
index 0333ceb..6d16d2a 100644
--- a/src/code_node/analysis/model.py
+++ b/src/code_node/analysis/model.py
@@ -35,6 +35,7 @@ class CodeStep:
# [('S3 PutObject Bucket', 'env ARCHIVE_BUCKET')]
when: list[str] = field(default_factory=list) # a 'def' step: the tests of the ifs around the line calling it,
# outermost first: ['args.command == "init"'], ['check(order)'] after `if not check(order): continue`
+ ends: str = "" # an 'if', 'else' or 'except' whose lines end by leaving its loop: 'continue' or 'break'
def walk(self) -> Iterable[CodeStep]:
"""
diff --git a/src/code_node/analysis/reader.py b/src/code_node/analysis/reader.py
index 69e4176..7ddda2b 100644
--- a/src/code_node/analysis/reader.py
+++ b/src/code_node/analysis/reader.py
@@ -51,6 +51,7 @@
_doc_line,
_ends_early,
_is_main_guard,
+ _jump,
_names_in,
_negated,
_parse,
@@ -548,10 +549,14 @@ def _if(self, stmt: ast.If, frame: _Frame) -> list[CodeStep]:
frame.guards.append(_negated(stmt.test))
orelse = self._block(stmt.orelse, frame)
frame.guards.pop()
- if orelse:
- rest = [CodeStep(frame.file, stmt.orelse[0].lineno - 1, "else:", "else", steps=orelse)]
- if head.notes or head.calls or body or rest:
- return [self._step(stmt, frame, f"if {test}:", "if", head, body), *rest]
+ ends = _jump(stmt.orelse)
+ if orelse or ends:
+ rest = [CodeStep(frame.file, stmt.orelse[0].lineno - 1, "else:", "else", steps=orelse, ends=ends)]
+ ends = _jump(stmt.body)
+ if head.notes or head.calls or body or rest or ends:
+ step = self._step(stmt, frame, f"if {test}:", "if", head, body)
+ step.ends = ends
+ return [step, *rest]
return []
def _with(self, stmt: Any, frame: _Frame) -> list[CodeStep]:
@@ -628,8 +633,10 @@ def _try(self, stmt: Any, frame: _Frame) -> list[CodeStep]:
if silent:
code = " ".join([header] + [_unparse(s, 40) for s in h.body])
self._spot("swallow", frame.file, h.lineno, frame.function, code)
- if hbody or head.notes:
+ ends = _jump(h.body)
+ if hbody or head.notes or ends:
handlers.append(self._step(h, frame, header, "except", head, hbody))
+ handlers[-1].ends = ends
orelse = self._block(stmt.orelse, frame)
final = self._block(stmt.finalbody, frame)
if not (body or handlers or orelse or final):
diff --git a/src/code_node/analysis/syntax.py b/src/code_node/analysis/syntax.py
index f413c2f..cbcf8a8 100644
--- a/src/code_node/analysis/syntax.py
+++ b/src/code_node/analysis/syntax.py
@@ -57,6 +57,14 @@ def _ends_early(body: list[Any]) -> bool:
return bool(body) and isinstance(body[-1], (ast.Return, ast.Raise, ast.Continue, ast.Break))
+def _jump(body: list[Any]) -> str:
+ """
+ How a block leaves its loop, when its last line does: 'continue', 'break', or ''.
+ """
+ last = body[-1] if body else None
+ return "continue" if isinstance(last, ast.Continue) else "break" if isinstance(last, ast.Break) else ""
+
+
def _secret_spots(node: Any) -> list[tuple[str, Any]]:
"""
The secret-looking literals in a piece of code, with the name each is given to: a password=, token= or key given
diff --git a/src/code_node/report/build.py b/src/code_node/report/build.py
index 6cc573c..afac53c 100644
--- a/src/code_node/report/build.py
+++ b/src/code_node/report/build.py
@@ -1,5 +1,6 @@
"""
-The page code-node draws for one entry point: tabs for the graph and the walk through its steps.
+The page code-node draws for one entry point: tabs for the graph, the decisions it makes and the walk through its
+steps.
"""
from __future__ import annotations
@@ -12,6 +13,7 @@
from ..analysis.findings import _listed, _short_name
from ..analysis.graph import CodeGraph, code_graph, everything_graph, graph_views
from ..analysis.model import CodeMap
+from .flow import _Flow, flow_entries
from .graph import _Graph
from .tabs import _Tabs
from .walk import _Walk, walk_stops
@@ -62,7 +64,7 @@ def page_blocks(
editor: str = "",
) -> list[Any]:
"""
- The whole page for one entry point, as report blocks: tabs for the graph and the walk through its steps, with the
+ The whole page for one entry point, as report blocks: tabs for the graph, its decisions and the walk through its steps, with the
entry point and the folder beside them. sources is the code read, {file: its text}: with it, a box of the graph
clicked shows its code. editor is a link from editor_link(), to open the code at a line in an editor.
"""
@@ -111,6 +113,20 @@ def page_blocks(
],
)
)
+ event = next((n for n in graph.nodes if n.kind == "event"), None)
+ routes = any(route for _, route in flow_entries(cmap.steps)) # a web app: a flow for each route
+ flow = _Flow(
+ cmap.steps,
+ f"How {'each route' if routes else f'{entry}()'} decides what to do: each question it asks, and where each "
+ "answer goes"
+ if entry
+ else "",
+ comes_in=(event.label, event.sub) if event else ("", ""),
+ )
+ decisions = sum(
+ 1 for top in cmap.steps for s in top.walk() if s.kind in ("if", "try") and not s.code.startswith("case ")
+ )
+ tabs.append(("Decisions", _plural(decisions, "decision"), [flow]))
tabs.append(("Step by step", _plural(len(walk_stops(walk)), "step"), [walk]))
read = f"{_plural(cmap.files, 'file')}, {cmap.lines:,} lines, nothing run" if cmap.files else ""
blocks.append(
diff --git a/src/code_node/report/flow.py b/src/code_node/report/flow.py
new file mode 100644
index 0000000..93890f7
--- /dev/null
+++ b/src/code_node/report/flow.py
@@ -0,0 +1,952 @@
+"""
+_Flow: the decisions each call makes, as a flowchart drawn top to bottom. What comes in, then each line that does
+something worth knowing, each `if` as a question with an arrow for each answer, each loop as a box with an arrow back
+to its top, each `try` with the way taken when a line in it raises, and where the function ends.
+
+No JavaScript: one SVG for each flow. The path that goes on runs down the middle; an answer whose lines end early
+(return, raise, skip to the next item, leave the loop) sits beside it, so the usual path reads as one line. A call to
+one of the code's own functions is a box with what it reaches; one that makes decisions of its own is opened in place,
+up to _OPEN_DEPTH calls deep and _OPEN_TALL high. One opened nowhere gets a flow of its own under the first, and its
+box links to it.
+"""
+
+from __future__ import annotations
+
+import html
+import re
+from dataclasses import dataclass, field
+from typing import Callable
+
+from ..kit.blocks import _Report, _draws
+from ..kit.fmt import _plural
+from ..kit.text import _esc
+
+from ..analysis.findings import _short_name
+from ..analysis.model import CodeStep
+from .brands import AWS_LOGOS, SERVICE_LOGOS, fills, logo_for
+from .graph import _g_mono, _g_sans
+from .walk import _SAID_FIRST, _note_said
+
+_GAP = 30 # between boxes on a path
+_SIDE = 56 # between the path down the middle and an answer beside it
+_PAD = 28 # around the drawing
+_BOX_LEAST, _BOX_MOST = 230, 340 # a box's width
+_ROW = 21 # a line a box lists: what a call reaches
+_ROWS_MOST = 4 # lines a call's box lists, at most
+_END_MOST = 300 # an end's words, at most this wide
+_QUESTION_MOST = 320 # a question's code, at most this wide
+
+_OPEN_DEPTH = 2 # calls opened in place, at most this many deep
+_OPEN_TALL = 1400 # ...and at most this tall; a call opened nowhere gets a flow of its own
+_FLOWS_MOST = 24 # flows drawn, at most: a web app's routes, then the calls opened nowhere
+
+_REACHES = ("aws-", "lib-", "http") # the tags of what a line reaches outside the code
+
+_KIND_OF = { # a tag -> the colour its box takes, as the graph's arrows do
+ "aws-reads": "reads",
+ "lib-reads": "reads",
+ "aws-writes": "writes",
+ "lib-writes": "writes",
+ "aws-sends": "sends",
+ "lib-sends": "sends",
+ "http": "sends",
+ "aws-runs": "runs",
+ "lib-runs": "runs",
+ "event": "event",
+ "env": "env",
+ "warn": "warn",
+}
+
+
+@dataclass
+class _Flow:
+ """
+ How each call decides what to do: steps are the map's (CodeMap.steps), one 'def' step for the entry point, or for
+ a web app the app with a 'def' step for each route. comes_in is what the entry point is called with, as the graph
+ says it: ('An S3 event', "what it's called with").
+ """
+
+ steps: list[CodeStep]
+ title: str = ""
+ comes_in: tuple[str, str] = ("", "")
+ empty: str = "(no calls to draw)"
+
+
+# ---------------------------------------------------------------- what's drawn
+
+
+@dataclass
+class _Box:
+ """
+ A piece of a flow, drawn at any place: its size, where the path runs through it (middle, from its left), and
+ whether the code goes on past its bottom.
+ """
+
+ width: float
+ height: float
+ middle: float
+ parts: list[Callable[[float, float], str]] = field(default_factory=list)
+ goes_on: bool = True
+ leaving: str = "" # said on the arrow that leaves it: 'when every one is done'
+
+ def draw(self, x: float, y: float) -> str:
+ return "".join(part(x, y) for part in self.parts)
+
+
+_NOTHING = _Box(0, 0, 0)
+
+
+def _clip(text: str, room: float, measure: Callable[[str], float]) -> str:
+ if measure(text) <= room:
+ return text
+ while text and measure(text + "…") > room:
+ text = text[:-1]
+ return text + "…"
+
+
+def _mono(size: float) -> Callable[[str], float]:
+ return lambda text: _g_mono(text, size)
+
+
+def _sans(size: float, bold: bool = False) -> Callable[[str], float]:
+ return lambda text: _g_sans(text, size, bold)
+
+
+def _arrow(points: list[tuple[float, float]], css: str = "", head: bool = True) -> str:
+ """
+ A line through the points, with an arrowhead at the last one.
+ """
+ path = "M" + " L".join(f"{x:g} {y:g}" for x, y in points)
+ out = f'
{_esc(block.empty)}
" + own: dict[str, str] = {} # a call opened nowhere -> the id of its flow + later: list[CodeStep] = [] + + def anchor(call: CodeStep) -> str: + if call.name not in own: + own[call.name] = f"{group}-{len(own)}" + later.append(call) + return own[call.name] + + out = [f"{_esc(_short_name(entry.name))}(){f" · {_esc(route)}" if route else ""}
Inside {_esc(_short_name(call.name))}()'
+ f' {_esc(call.file)} line {call.line}
And {_plural(left, "more flow")}, left out to keep the page light.
') + return "".join(out) + _KEY + + +# ---------------------------------------------------------------- in text + + +def _said(steps: list[CodeStep]) -> str: + """ + Where an answer goes, in a few words: its first line that does something, then how it ends. + """ + words = [] + for step in steps: + if step.kind in ("do", "def"): + own = [c for c in step.calls if c.kind == "def"] + reach = next(((k, t) for k, t in step.notes if k in _SAID_FIRST or k == "log"), None) + if own: + words.append("calls " + ", ".join(f"{_short_name(c.name)}()" for c in own)) + elif reach: + said = _plain(_note_said(*reach)) + words.append(said[:1].lower() + said[1:]) + elif step.kind == "return": + words.append(f"returns {step.code.removeprefix('return').strip() or 'None'}") + elif step.kind == "raise": + words.append(f"raises {step.code.removeprefix('raise').strip() or 'the error again'}") + elif step.kind in ("if", "loop", "try"): + words.append("decides more (below)") + return ", then ".join(words[:3]) + (" …" if len(words) > 3 else "") + + +_ENDS = {"continue": "skips to the next item", "break": "leaves the loop"} + + +def _answer_said(step: CodeStep | None) -> str: + if step is None: + return "goes on" + said = _said(step.steps) + ends = _ENDS.get(step.ends, "") + return ", then ".join(filter(None, [said, ends])) or "goes on" + + +def _text_lines(steps: list[CodeStep], pad: int, out: list[str], answered: bool = False) -> None: + """ + Each decision under steps as a line saying where each answer goes, its loops, trys and ends, indented as the code + is; the calls that make decisions of their own after the line calling them. answered: steps are an answer's, + whose end its question's line said. + """ + indent = " " * pad + i = 0 + while i < len(steps): + step = steps[i] + under: list[CodeStep] = [step] + if step.kind == "if" and not step.code.startswith("match "): + chain = [step] + while i + 1 < len(steps) and steps[i + 1].kind == "if" and steps[i + 1].code.startswith("elif "): + i += 1 + chain.append(steps[i]) + otherwise = None + if i + 1 < len(steps) and steps[i + 1].kind == "else": + i += 1 + otherwise = steps[i] + for k, asked in enumerate(chain): + test, yes = _test(asked.code), _answer_said(asked) + no = "asks the next question" if k + 1 < len(chain) else _answer_said(otherwise) + if test.startswith("not ") and len(chain) == 1: + test, yes, no = test[4:], no, yes + out.append(f"{asked.line:>5} {indent}{test}? yes: {yes} · no: {no}") + under = [*chain, *([otherwise] if otherwise else [])] + elif step.kind == "if": + out.append(f"{step.line:>5} {indent}{step.code.rstrip(':')}") + elif step.kind == "loop": + out.append(f"{step.line:>5} {indent}{step.code.rstrip(':')} (back to its top for each)") + elif step.kind == "try": + out.append(f"{step.line:>5} {indent}tries:") + elif step.kind == "except": + out.append(f"{step.line:>5} {indent}{step.code.rstrip(':')}: {_answer_said(step)}") + elif step.kind == "return" and not answered: + out.append(f"{step.line:>5} {indent}returns {step.code.removeprefix('return').strip() or 'None'}") + elif step.kind == "raise" and not answered: + out.append(f"{step.line:>5} {indent}raises {step.code.removeprefix('raise').strip() or 'the error again'}") + for each in under: + for call in each.calls: + if call.kind == "def" and _decisions(call) and not call.seen: + out.append(f"{each.line:>5} {indent} ↳ {_short_name(call.name)}() {call.file} line {call.line}") + _text_lines(call.steps, pad + 4, out) + if each.kind != "do": + _text_lines(each.steps, pad + 2, out, answered=each.kind in ("if", "else", "except")) + i += 1 + + +@_draws(_Flow, "text") +def _flow_lines(block: _Flow, report: _Report) -> list[str]: + out = ["", f"-- {block.title} --"] if block.title else [""] + entries = flow_entries(block.steps) + if not entries: + return out + [block.empty] + for entry, route in entries[:_FLOWS_MOST]: + out.append(f" {_short_name(entry.name)}() {entry.file} line {entry.line}{f' ({route})' if route else ''}") + if not _decisions(entry): + out.append(" (no decisions: one path from start to end)") + continue + _text_lines(entry.steps, 0, out) + return out diff --git a/src/code_node/report/outline.py b/src/code_node/report/outline.py index f816dd3..e288247 100644 --- a/src/code_node/report/outline.py +++ b/src/code_node/report/outline.py @@ -210,7 +210,8 @@ def walk(steps: list[CodeStep], pad: int, called: bool) -> None: ) walk(step.steps, pad + (4 if called else 2), False) continue - out.append(f"{step.line or '':>5} {' ' * pad}{step.code}{comment(step.notes, step=step)}") + ends = f"then {step.ends}" if step.ends else "" + out.append(f"{step.line or '':>5} {' ' * pad}{step.code}{comment(step.notes, ends, step=step)}") for call in step.calls: walk([call], pad + 4, True) walk(step.steps, pad + 2, False) diff --git a/src/code_node/report/style.py b/src/code_node/report/style.py index 51e882d..2d722d2 100644 --- a/src/code_node/report/style.py +++ b/src/code_node/report/style.py @@ -443,6 +443,55 @@ .cnd .walk .wfacts code{font:12.5px var(--mono)} .cnd .walk :is(.wfm,.wfv){opacity:.6} .cnd .walk .wthen{margin-top:10px;font-size:12px;opacity:.6} +.cnd .cdf{margin:6px 0 18px;--fe:rgba(127,127,127,.85);--fl:light-dark(#7c3aed,#a78bfa);--fa:light-dark(#d97706,#f59e0b);--fx:light-dark(#dc2626,#f87171);--fbg:var(--jp-layout-color0,var(--vscode-editor-background,Canvas))} +.cnd .cdf .fhd{margin:0 0 6px;font-size:13.5px}.cnd .cdf .fhd small{opacity:.6;margin-left:6px} +.cnd .cdfc{width:fit-content;max-width:100%;overflow:auto;border:1px solid rgba(127,127,127,.2);border-radius:14px;background-color:rgba(127,127,127,.035);background-attachment:local;background-size:18px 18px; + background-image:radial-gradient(circle at 1px 1px,rgba(127,127,127,.3) 1px,transparent 0);background-position:9px 9px} +.cnd .cdfc>svg{display:block;max-width:none;height:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif} +.cnd .cdf svg text{fill:currentColor} +.cnd .cdf :is(.mono,.fr,.fp text,.fq text,.ff){font-family:var(--mono)} +.cnd .cdf .fe{fill:none;stroke:var(--fe);stroke-width:1.6;stroke-linejoin:round}.cnd .cdf .fh{fill:var(--fe)} +.cnd .cdf .fe.back{stroke:var(--fl);stroke-dasharray:5 4}.cnd .cdf .fh.back{fill:var(--fl)} +.cnd .cdf .fe.fail{stroke:var(--fa);stroke-dasharray:5 4}.cnd .cdf .fh.fail{fill:var(--fa)} +.cnd .cdf .fj{fill:var(--fe)} +.cnd .cdf .fw rect{fill:var(--fbg);stroke:rgba(127,127,127,.3)}.cnd .cdf .fw text{font-size:11.5px;font-weight:650;fill-opacity:.65} +.cnd .cdf :is(.fwyes,.fwno,.fwl,.fwf) text{fill-opacity:1} +.cnd .cdf .fwyes text,.cnd .fkey .fwyes{fill:light-dark(#15803d,#4ade80);color:light-dark(#15803d,#4ade80)} +.cnd .cdf .fwno text,.cnd .fkey .fwno{fill:light-dark(#b91c1c,#f87171);color:light-dark(#b91c1c,#f87171)} +.cnd .cdf .fwl text{fill:var(--fl)}.cnd .cdf .fwf text{fill:var(--fa)} +.cnd .cdf .fbg{fill:var(--fbg);stroke:rgba(127,127,127,.32);filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.07)) drop-shadow(0 6px 16px rgba(15,23,42,.06))} +.cnd .cdf .fbd{fill:var(--t,rgba(127,127,127,.12));stroke:var(--c,rgba(127,127,127,.5));stroke-opacity:.22} +.cnd .cdf .fbi{fill:none;stroke:var(--c,currentColor);stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.cnd .cdf .fbi.fill{fill:var(--c,currentColor);stroke:none} +.cnd .cdf .fbl,.cnd .cdf .fgt{font:600 15px Georgia,serif} +.cnd .cdf .ft{font-size:13.5px;font-weight:650}.cnd .cdf .ft.mono{font-size:13px} +.cnd .cdf .fs{font-size:12px;fill-opacity:.62}.cnd .cdf .fr{font-size:12px;fill-opacity:.88} +.cnd .cdf .ff{font-size:11.5px;fill:light-dark(#2563eb,#60a5fa)}.cnd .cdf a:hover .ff{text-decoration:underline} +.cnd .cdf .fl{stroke:rgba(127,127,127,.2);fill:none} +.cnd .cdf .fdot{fill:var(--c,rgba(127,127,127,.6))} +.cnd .cdf .k-event{--c:#16a34a;--t:rgba(22,163,74,.12)}.cnd .cdf .k-env{--c:#0d9488;--t:rgba(13,148,136,.13)} +.cnd .cdf .k-reads{--c:#3b82f6;--t:rgba(59,130,246,.11)}.cnd .cdf .k-writes{--c:#ea580c;--t:rgba(234,88,12,.11)} +.cnd .cdf .k-sends{--c:#db2777;--t:rgba(219,39,119,.1)}.cnd .cdf .k-runs{--c:#9333ea;--t:rgba(147,51,234,.1)} +.cnd .cdf .k-warn{--c:#f59e0b}.cnd .cdf .k-quiet{--c:#64748b} +.cnd .cdf .k-entry .fbg{stroke:currentColor;stroke-opacity:.9;stroke-width:1.5}.cnd .cdf .k-entry .fbd{fill:currentColor;stroke:none}.cnd .cdf .k-entry .fbi.fill{fill:var(--fbg)} +.cnd .cdf .fq path{fill:light-dark(#fffbeb,rgba(245,158,11,.1));stroke:var(--fa);stroke-width:1.5}.cnd .cdf .fq text{font-size:13px;font-weight:600} +.cnd .cdf .fx .fxb{fill:rgba(127,127,127,.04);stroke:rgba(127,127,127,.5);stroke-dasharray:6 4} +.cnd .cdf .fxt{font-size:13px;font-weight:650}.cnd .cdf .fgl{fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.cnd .cdf .fgl.fill{fill:currentColor;stroke:none} +.cnd .cdf .floop .fxb{fill:light-dark(rgba(124,58,237,.05),rgba(167,139,250,.07));stroke:var(--fl);stroke-opacity:.5}.cnd .cdf .floop .fgl{stroke:var(--fl)} +.cnd .cdf .ftry .fxb{fill:rgba(217,119,6,.04);stroke:var(--fa);stroke-opacity:.6}.cnd .cdf .ftry .fgl{stroke:var(--fa)} +.cnd .cdf .felse .fgl{stroke-opacity:.6} +.cnd .cdf .fp rect{fill:var(--fbg);stroke:rgba(127,127,127,.45);stroke-width:1.3}.cnd .cdf .fp text{font-size:12.5px} +.cnd .cdf .fret rect{fill:currentColor;stroke:none}.cnd .cdf .fret text{fill:var(--fbg)}.cnd .cdf .fret .fgl.fill{fill:var(--fbg)} +.cnd .cdf .fraise rect{fill:light-dark(rgba(220,38,38,.07),rgba(248,113,113,.1));stroke:var(--fx)}.cnd .cdf .fraise text{fill:var(--fx)}.cnd .cdf .fraise .fgl.fill{fill:var(--fx)} +.cnd .cdf .fwarn rect{fill:rgba(245,158,11,.08);stroke:var(--fa)}.cnd .cdf .fwarn text{fill:var(--fa)}.cnd .cdf .fwarn .fgl.fill{fill:var(--fa)} +.cnd .cdf .fgx{stroke:#fff;stroke-width:2;stroke-linecap:round} +.cnd .cdf .fskip rect{fill:light-dark(rgba(124,58,237,.05),rgba(167,139,250,.08));stroke:var(--fl);stroke-dasharray:4 3}.cnd .cdf .fskip text{fill:var(--fl)}.cnd .cdf .fskip .fgl{stroke:var(--fl)} +.cnd .fmore{opacity:.7;font-size:13px} +.cnd .fkey{display:flex;flex-wrap:wrap;gap:8px 18px;margin:2px 0 8px;font-size:12.5px;align-items:center;opacity:.85} +.cnd .fkey svg{vertical-align:middle;margin-right:6px}.cnd .fkey b{font-weight:650} +.cnd .fkey .kq{fill:light-dark(#fffbeb,rgba(245,158,11,.12));stroke:light-dark(#d97706,#f59e0b);stroke-width:1.4} +.cnd .fkey .kl{fill:none;stroke:light-dark(#7c3aed,#a78bfa);stroke-dasharray:4 3} +.cnd .fkey .kr{fill:currentColor}.cnd .fkey .kx{fill:light-dark(rgba(220,38,38,.08),rgba(248,113,113,.1));stroke:light-dark(#dc2626,#f87171)} +.cnd .fkey .kf{stroke:light-dark(#d97706,#f59e0b);stroke-dasharray:4 3;stroke-width:1.6} .cnd .walk .wf{display:none;position:absolute;left:var(--lw);right:0;top:var(--ch);bottom:0;flex-direction:column;border-left:1px solid rgba(127,127,127,.2);background:var(--bg)} .cnd .walk .wfn>.wr:checked~.wf{display:flex;animation:cnd-fade .25s ease} .cnd .walk .wfh{animation:cnd-in .25s ease} diff --git a/src/code_node/report/walk.py b/src/code_node/report/walk.py index 0a092eb..ca750fa 100644 --- a/src/code_node/report/walk.py +++ b/src/code_node/report/walk.py @@ -46,6 +46,11 @@ "raise": "Raises an error: the function stops here, and so does each caller that doesn't catch it.", } +_ENDS_SAYS = { # how the lines under it end, when they leave the loop: CodeStep.ends + "continue": "Then it skips the rest of the loop, on to the next item.", + "break": "Then it leaves the loop.", +} + _AWS_DOES = { # an AWS call, by what it does, as a sentence: 'Reads from S3 (GetObject)' "reads": "Reads from {}", "writes": "Writes to {}", @@ -370,6 +375,8 @@ def _says( ) elif step.kind in _KIND_SAYS: said.append(_KIND_SAYS[step.kind]) + if step.ends: + said.append(_ENDS_SAYS[step.ends]) own = [call for call in step.calls if call.kind == "def"] if own: it = "it" if len(own) == 1 else "each" diff --git a/tests/snapshots/books-api.txt b/tests/snapshots/books-api.txt index 728da66..f5f092a 100644 --- a/tests/snapshots/books-api.txt +++ b/tests/snapshots/books-api.txt @@ -90,6 +90,19 @@ flowchart LR classDef warn stroke:#f59e0b,stroke-width:2px class f3,f4,f7,lib_sql_postgresql,http warn +-- How each route decides what to do: each question it asks, and where each answer goes -- + get_book() books/routes.py line 28 (GET /books/{book_id}) + 30 cached? yes: returns json.loads(cached) · no: goes on + 34 returns found + search() books/routes.py line 38 (GET /books) + (no decisions: one path from start to end) + add_review() books/routes.py line 43 (POST /books/{book_id}/reviews) + (no decisions: one path from start to end) + set_cover() books/routes.py line 49 (PUT /books/{book_id}/cover) + (no decisions: one path from start to end) + export() books/routes.py line 56 (GET /books/{book_id}/export) + (no decisions: one path from start to end) + -- When Python imports it, once, before the first call -- 7 from . import routes ↳ books/routes.py, run when it's imported diff --git a/tests/snapshots/cart-service.txt b/tests/snapshots/cart-service.txt index 52e7d4e..07bccad 100644 --- a/tests/snapshots/cart-service.txt +++ b/tests/snapshots/cart-service.txt @@ -66,6 +66,11 @@ flowchart LR classDef warn stroke:#f59e0b,stroke-width:2px class env,f0,f3,aws_dynamodb,aws_sns,lib_sql_postgresql,lib_llm_openai warn +-- How on_checkout() decides what to do: each question it asks, and where each answer goes -- + on_checkout() cart/app.py line 20 + 25 for item in cart['items'] (back to its top for each) + 32 returns {'statusCode': 200, 'body': json.dumps(receipt)} + -- When Python imports it, once, before the first call -- 13 from .assistant import summary ↳ cart/assistant.py, run when it's imported diff --git a/tests/snapshots/orders-service.txt b/tests/snapshots/orders-service.txt index 3a986d4..a28226f 100644 --- a/tests/snapshots/orders-service.txt +++ b/tests/snapshots/orders-service.txt @@ -76,6 +76,13 @@ flowchart LR classDef warn stroke:#f59e0b,stroke-width:2px class f7,http warn +-- How lambda_handler() decides what to do: each question it asks, and where each answer goes -- + lambda_handler() orders/handler.py line 30 + 32 for record in event['Records'] (back to its top for each) + 35 for order in read_orders(bucket, key) (back to its top for each) + 36 check(order)? yes: goes on · no: logs a warning, then skips to the next item + 42 returns {'saved': saved} + -- When Python imports it, once, before the first call -- 12 from .notify import announce ↳ orders/notify.py, run when it's imported @@ -95,7 +102,7 @@ flowchart LR 19 ↳ def read_orders(bucket, key): # orders/handler.py 21 body = s3.get_object(Bucket=bucket, Key=key)['Body'].read() # S3 GetObject (reads) · Bucket ← event Records[].s3.bucket.name · Key ← event Records[].s3.object.key 22 return [json.loads(line) for line in body.splitlines() if line] - 36 if not check(order): + 36 if not check(order): # then continue 25 ↳ def check(order): # orders/handler.py 27 return bool(order.get('id')) and bool(order.get('customer')) and (order.get('total', 0) > 0) 37 log.warning('skipping %s', order.get('id')) # logs a warning diff --git a/tests/unit/test_reader.py b/tests/unit/test_reader.py index 914455e..a82b327 100644 --- a/tests/unit/test_reader.py +++ b/tests/unit/test_reader.py @@ -241,3 +241,27 @@ def test_the_entry_point_s_file_is_found_from_its_name() -> None: assert entry_file("tool.main", names) == ("src/tool/__init__.py", "src/") # a package's __init__ assert entry_file("app.handler", names) == ("app.py", "") assert entry_file("missing.main", names) is None + + +def test_an_if_or_except_that_leaves_its_loop_says_how() -> None: + code = """ +import requests + +def handler(event, context): + for url in event["urls"]: + if not url: + continue + try: + answer = requests.get(url, timeout=5) + except requests.Timeout: + continue + if answer.ok: + break +""" + cmap = map_code({"app.py": code}, "app.handler", platform="lambda") + loop = cmap.steps[0].steps[0] + assert [(s.kind, s.code, s.ends) for s in loop.steps if s.kind in ("if", "except")] == [ + ("if", "if not url:", "continue"), # kept for its continue alone: it decides what runs + ("except", "except requests.Timeout:", "continue"), + ("if", "if answer.ok:", "break"), + ] diff --git a/tests/unit/test_report.py b/tests/unit/test_report.py index e51587e..d837d89 100644 --- a/tests/unit/test_report.py +++ b/tests/unit/test_report.py @@ -142,6 +142,8 @@ def test_services_show_their_real_logos(tmp_path: Path) -> None: page = render_page(page_blocks_for(project, "app.handler"), "x") assert page.count('