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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Binary file added docs/images/decisions-dark.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/decisions-light.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/page-dark.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/page-light.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/steps-dark.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/steps-light.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ Select a highlighted line to see the path it takes, or point at a box.</figcapti

## The page

The graph is one of two tabs on an HTML page. The other walks through each call step by step. It's one file that works
offline, and everything on it but opening a box's hidden calls works without JavaScript.
The graph is one of three tabs on an HTML page. The next draws each call as a flowchart of the decisions it makes,
and the last walks through each call step by step. It's one file that works offline, and everything on it but opening
a box's hidden calls works without JavaScript.

<figure class="cn-shot" markdown>
![A code-node page: the graph in its tab](images/page-light.webp#only-light){ loading=lazy }
Expand Down
39 changes: 37 additions & 2 deletions docs/reading.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# 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 }

| 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 }
Expand Down Expand Up @@ -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.

![The Decisions tab](images/decisions-light.webp#only-light){ loading=lazy }
![The Decisions tab](images/decisions-dark.webp#only-dark){ 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
Expand Down
2 changes: 2 additions & 0 deletions scripts/shots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions src/code_node/analysis/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
Expand Down
17 changes: 12 additions & 5 deletions src/code_node/analysis/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
_doc_line,
_ends_early,
_is_main_guard,
_jump,
_names_in,
_negated,
_parse,
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions src/code_node/analysis/syntax.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions src/code_node/report/build.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading