From 0ad33e8164ee8f35ae968b7a9ea66aa731dfe6fe Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Wed, 12 Aug 2026 13:47:09 -0400 Subject: [PATCH 1/3] Graph can now scope by status --- README.md | 4 +- src/docket/__init__.py | 2 +- src/docket/cli/commands.py | 9 ++-- src/docket/cli/grammar.py | 27 +++++++---- src/docket/core/graph.py | 17 +++++++ src/docket/core/store.py | 7 ++- src/docket/core/ticket.py | 19 +++++++- src/docket/server.py | 12 +++-- tests/test_cli.py | 94 ++++++++++++++++++++++++++++++++++---- tests/test_graph.py | 37 ++++++++++++++- tests/test_server.py | 30 ++++++++++++ 11 files changed, 223 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 8bd1979..400d48d 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Everything else works on the set: ```bash docket new CORE "Skirmish setup" [-r CORE-9,GEN-3] [-p 1] [-b TEXT] docket list [-s todo] [-k CORE] [-m 2] [-r] -docket graph [-i CORE-14 | -k GEN] [-o FILE] +docket graph [-i CORE-14 | -k GEN | -s todo] [-o FILE] docket key list | add KEY "desc" [-r TEXT] | remove KEY docket validate | deploy PATH | upgrade PATH ``` @@ -121,7 +121,7 @@ Every short flag has a long form (`-k/--key`, `-p/--priority`, `-m/--priority-ma | `create_ticket(key, title, body?, requires?, priority?)` | Allocates the id, writes the file. | | `update_ticket(id, title?, priority?, requires?, requires_add?, requires_remove?)` | Those three fields only. | | `set_status(id, status)` | Writes frontmatter and moves the file together. | -| `graph(id?, key?)` | Mermaid source. | +| `graph(id?, key?, status?)` | Mermaid source. | | `list_keys()` | The registered keys. | | `add_key(key, description, rationale)` | After the agent has asked the user. | | `validate()` | Structured findings. | diff --git a/src/docket/__init__.py b/src/docket/__init__.py index ca50a01..d9cc4bc 100644 --- a/src/docket/__init__.py +++ b/src/docket/__init__.py @@ -8,4 +8,4 @@ # No type check to comply with hatch's requirements. # Do not re-add. -__version__ = "1.2.0" +__version__ = "1.3.0" diff --git a/src/docket/cli/commands.py b/src/docket/cli/commands.py index d2aae30..c3aa09d 100644 --- a/src/docket/cli/commands.py +++ b/src/docket/cli/commands.py @@ -18,7 +18,7 @@ from docket.cli.output import STATUS_STYLES, Output, buildContextTable, relativeToRoot from docket.core.config import Config from docket.core.deploy import DeployReport, deploy, upgrade -from docket.core.graph import Readiness, ResolvedGraph, dependencyContext, readyTickets, resolveGraph, subgraphForId, subgraphForKey, ticketReadiness +from docket.core.graph import Readiness, ResolvedGraph, dependencyContext, readyTickets, resolveGraph, subgraphForId, subgraphForKey, subgraphForStatus, ticketReadiness from docket.core.inputs import requireWritableFile, writeFile from docket.core.mermaid import renderGraph from docket.core.store import Store, TicketResult, TicketSet @@ -325,17 +325,20 @@ def commandGraph(args: argparse.Namespace, store: Store, output: Output) -> int: Returns the process exit code. """ - ticketId, key = resolveGraphScope(args.scope, args.id, args.key) + ticketId, key, status = resolveGraphScope(args.scope, args.id, args.key, args.status) graph: ResolvedGraph = resolveGraph(store.loadAll()) - # Scope the graph when asked. At most one of the two survives resolution. + # Scope the graph when asked. At most one of the three survives resolution. if ticketId is not None: graph = subgraphForId(graph, ticketId) elif key is not None: # For the same reason as `list`, an unregistered key here would render an empty graph rather than admitting the key does not exist. store.config.requireKnownKey(key) graph = subgraphForKey(graph, key) + elif status is not None: + # No equivalent check, because the vocabulary is fixed and both spellings are checked against it before they arrive. An empty result here is a true answer rather than a typo. + graph = subgraphForStatus(graph, status) source: str = renderGraph(graph) diff --git a/src/docket/cli/grammar.py b/src/docket/cli/grammar.py index d643ef6..4951408 100644 --- a/src/docket/cli/grammar.py +++ b/src/docket/cli/grammar.py @@ -184,34 +184,40 @@ def resolveListFilters(tokens: list[str], status: Optional[str], key: Optional[s return resolved[TOKEN_STATUS], resolved[TOKEN_KEY], None if ceiling is None else int(ceiling) -def resolveGraphScope(scope: Optional[str], ticketId: Optional[str], key: Optional[str]) -> tuple[Optional[str], Optional[str]]: +def resolveGraphScope(scope: Optional[str], ticketId: Optional[str], key: Optional[str], status: Optional[str]) -> tuple[Optional[str], Optional[str], Optional[str]]: """ Resolve the graph's scope from its bare token and its flags together. - An id and a key are told apart by shape, which is what makes `docket graph CORE-14` and `docket graph GEN` both unambiguous. The flags stay for the explicit form, and combining the two spellings is refused, since `argparse` can enforce that `--id` and `--key` exclude each other but cannot reach across to a positional. + An id, a key, and a status are told apart by shape, which is what makes `docket graph CORE-14`, `docket graph GEN`, and `docket graph todo` all unambiguous. The flags stay for the explicit form, and combining the two spellings is refused, since `argparse` can enforce that the flags exclude each other but cannot reach across to a positional. + + The three scopes remain exclusive rather than composing. A graph is scoped to one thing, and narrowing an already narrowed graph is what `list`'s filters are for. scope: The bare scope token, or `None`. ticketId: The id named by `--id`, or `None`. key: The key named by `--key`, or `None`. + status: The status named by `--status`, or `None`. - Returns the resolved `(id, key)` pair, at most one of which is set. + Returns the resolved `(id, key, status)` triple, at most one of which is set. """ if scope is None: - return ticketId, key + return ticketId, key, status - if ticketId is not None or key is not None: - raise ConflictingArgumentsError(f"'{scope}' already scopes the graph, so it cannot be combined with -i/--id or -k/--key.") + if ticketId is not None or key is not None or status is not None: + raise ConflictingArgumentsError(f"'{scope}' already scopes the graph, so it cannot be combined with -i/--id, -k/--key, or -s/--status.") kind: Optional[str] = classifyToken(scope) if kind == TOKEN_ID: - return scope, None + return scope, None, None if kind == TOKEN_KEY: - return None, scope + return None, scope, None + + if kind == TOKEN_STATUS: + return None, None, scope - raise InvalidArgumentError(f"Cannot read '{scope}' as a scope. Expected a ticket id, for example 'CORE-14', or a key.") + raise InvalidArgumentError(f"Cannot read '{scope}' as a scope. Expected a ticket id, for example 'CORE-14', a key, or a status ({', '.join(STATUSES)}).") def buildParser(config: Optional[Config] = None) -> argparse.ArgumentParser: @@ -254,10 +260,11 @@ def buildParser(config: Optional[Config] = None) -> argparse.ArgumentParser: listParser.add_argument("-r", "--ready", action="store_true", help="Keep only tickets whose dependencies are all done. A done ticket is never ready, so this never shows one.") graphParser: argparse.ArgumentParser = commands.add_parser("graph", help="Render the dependency graph as mermaid source.", formatter_class=RichHelpFormatter) - graphParser.add_argument("scope", nargs="?", metavar="SCOPE", help="What to scope to, read from its own shape: a ticket id, or a key. The flags below are the same two, named explicitly.") + graphParser.add_argument("scope", nargs="?", metavar="SCOPE", help=f"What to scope to, read from its own shape: a ticket id, a key, or a status ({', '.join(STATUSES)}). The flags below are the same three, named explicitly.") graphScope = graphParser.add_mutually_exclusive_group() graphScope.add_argument("-i", "--id", help="Scope to one ticket's ancestors and descendants.") graphScope.add_argument("-k", "--key", help=f"Scope to one key, plus its immediate cross-key neighbors. {keyOptions}") + graphScope.add_argument("-s", "--status", choices=STATUSES, help="Scope to the tickets with this status alone. Nothing outside it is borrowed, so an edge survives only when both of its ends carry the status.") graphParser.add_argument("-o", "--out", help="Write to a file rather than to stdout.") keyParser: argparse.ArgumentParser = commands.add_parser("key", help="Inspect and manage the key registry.", formatter_class=RichHelpFormatter) diff --git a/src/docket/core/graph.py b/src/docket/core/graph.py index 2a8198a..7f5e9a3 100644 --- a/src/docket/core/graph.py +++ b/src/docket/core/graph.py @@ -231,6 +231,23 @@ def subgraphForKey(graph: ResolvedGraph, key: str) -> ResolvedGraph: return scoped +def subgraphForStatus(graph: ResolvedGraph, status: str) -> ResolvedGraph: + """ + Scope a graph to the tickets carrying one status, and nothing else. + + Nothing is borrowed from outside, unlike the key scope. A key has a boundary worth drawing, since the work either side of it is still related, but the tickets around a status are only the same work at a different moment, so pulling them in would put every other status back on the page. An edge therefore survives only when both of its ends carry the status, which is what lets the result read as the ordering within that status alone. + + graph: The graph to scope. + status: The status to scope to. + + Returns the scoped graph. + """ + + members: set[str] = {node.id for node in graph.nodes.values() if node.status == status} + + return _restrict(graph, members, scope=status) + + def dependencyContext(ticketSet: TicketSet, ticketId: str) -> dict[str, list[dict[str, object]]]: """ Resolve the context a raw ticket file deliberately does not carry. diff --git a/src/docket/core/store.py b/src/docket/core/store.py index ab03531..ae17a63 100644 --- a/src/docket/core/store.py +++ b/src/docket/core/store.py @@ -12,10 +12,10 @@ from docket.core.atomic import writeTextAtomic from docket.core.config import Config -from docket.core.errors import ConflictingArgumentsError, InvalidPriorityError, InvalidStatusError, TicketNotFoundError, TicketParseError +from docket.core.errors import ConflictingArgumentsError, InvalidPriorityError, TicketNotFoundError, TicketParseError from docket.core.ids import buildFilename, nextId, parseId, requireValidKey from docket.core.inputs import requireText -from docket.core.ticket import STATUS_DONE, STATUSES, Ticket, buildBody, parseTicket, serializeTicket +from docket.core.ticket import STATUS_DONE, STATUSES, Ticket, buildBody, parseTicket, requireKnownStatus, serializeTicket from docket.core.titles import toTitleCase # MARK: Constants @@ -430,8 +430,7 @@ def setStatus(self, ticketId: str, status: str) -> Ticket: """ # Reject an unrecognized status at the boundary rather than writing it and leaving `validate` to find it later. - if status not in STATUSES: - raise InvalidStatusError(f"Status '{status}' is not one of {', '.join(STATUSES)}.") + requireKnownStatus(status) with self.config.exclusiveLock(): ticket: Ticket = self.__loadAllUnlocked().get(ticketId) diff --git a/src/docket/core/ticket.py b/src/docket/core/ticket.py index e77a6df..6da0bd9 100644 --- a/src/docket/core/ticket.py +++ b/src/docket/core/ticket.py @@ -12,7 +12,7 @@ import yaml -from docket.core.errors import TicketParseError +from docket.core.errors import InvalidStatusError, TicketParseError from docket.core.fields import readDict, readInt, readString, readStringList from docket.core.ids import keyOf @@ -158,6 +158,23 @@ def _representFlowList(dumper: yaml.SafeDumper, data: FlowList) -> yaml.Node: TicketDumper.add_representer(FlowList, _representFlowList) +def requireKnownStatus(status: str) -> str: + """ + Return the status unchanged, raising when it is not one of the fixed vocabulary. + + The vocabulary is closed, so every surface that accepts a status has the same check to make. It lives here beside the vocabulary itself rather than at each surface, the same way `requireKnownKey` sits beside the registry. + + status: The status to check. + + Returns the same status. + """ + + if status not in STATUSES: + raise InvalidStatusError(f"Status '{status}' is not one of {', '.join(STATUSES)}.") + + return status + + def splitFrontmatter(text: str) -> tuple[str, str]: """ Split raw file text into its frontmatter block and its body. diff --git a/src/docket/server.py b/src/docket/server.py index f4162d7..8330dec 100644 --- a/src/docket/server.py +++ b/src/docket/server.py @@ -32,10 +32,10 @@ from docket import __version__ from docket.core.config import Config, discoverConfig -from docket.core.graph import Readiness, ResolvedGraph, dependencyContext, resolveGraph, subgraphForId, subgraphForKey, ticketReadiness +from docket.core.graph import Readiness, ResolvedGraph, dependencyContext, resolveGraph, subgraphForId, subgraphForKey, subgraphForStatus, ticketReadiness from docket.core.mermaid import renderGraph from docket.core.store import Store, TicketResult, TicketSet -from docket.core.ticket import Ticket +from docket.core.ticket import Ticket, requireKnownStatus from docket.core.validate import ValidationReport, validate # MARK: Constants @@ -223,7 +223,7 @@ async def setStatus(id: str, status: str) -> str: @mcp.tool(name="graph") -async def graphTool(id: Optional[str] = None, key: Optional[str] = None) -> str: +async def graphTool(id: Optional[str] = None, key: Optional[str] = None, status: Optional[str] = None) -> str: """ Render the dependency graph as mermaid source. @@ -231,16 +231,20 @@ async def graphTool(id: Optional[str] = None, key: Optional[str] = None) -> str: id: Scope to one ticket's transitive ancestors and descendants. key: Scope to one key, plus its immediate cross-key neighbors, which are marked so the boundary is visible. + status: Scope to the tickets with this status alone, one of todo, wip, done. Nothing outside it is borrowed, so an edge survives only when both of its ends carry the status. """ store: Store = _store() graph: ResolvedGraph = resolveGraph(store.loadAll()) - # Scope when asked, preferring an id since it is the narrower request. + # Scope when asked, narrowest request first. if id is not None: graph = subgraphForId(graph, id) elif key is not None: graph = subgraphForKey(graph, key) + elif status is not None: + # The CLI has `choices` to reject a status nobody uses, and without the same check here an unreadable one would render an empty graph that reads as an answer. + graph = subgraphForStatus(graph, requireKnownStatus(status)) return _json({"scope": graph.scope, "nodeCount": len(graph), "mermaid": renderGraph(graph)}) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8f7ae0b..a71d625 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -842,6 +842,11 @@ def testGraphScopeFlagsAreMutuallyExclusive(inRepo: Path) -> None: assert excInfo.value.code == EXIT_USAGE + with pytest.raises(SystemExit) as excInfo: + main(["graph", "--status", "todo", "--key", "CORE"]) + + assert excInfo.value.code == EXIT_USAGE + def testGraphScopesFromABareToken(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: """ @@ -859,9 +864,61 @@ def testGraphScopesFromABareToken(inRepo: Path, capsys: pytest.CaptureFixture[st assert "CORE_1" in capsys.readouterr().out +def testGraphScopesToAStatus(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + A status word is the third shape the one positional reads, and it keeps only the tickets carrying that status. + """ + + main(["new", "CORE", "App Shell"]) + main(["new", "GEN", "Battlescape", "--requires", "CORE-1"]) + main(["CORE-1", "done"]) + capsys.readouterr() + + assert main(["graph", "todo"]) == EXIT_OK + + output: str = capsys.readouterr().out + assert "GEN_1" in output + assert "CORE_1" not in output + + +def testGraphScopedToAStatusDropsAnEdgeLeavingIt(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + Nothing outside the status is borrowed, so an edge survives only when both of its ends carry it. + """ + + main(["new", "CORE", "App Shell"]) + main(["new", "GEN", "Battlescape", "--requires", "CORE-1"]) + main(["new", "GEN", "Skirmish Setup", "--requires", "GEN-1"]) + main(["CORE-1", "done"]) + capsys.readouterr() + + assert main(["graph", "--status", "todo"]) == EXIT_OK + + output: str = capsys.readouterr().out + + # The dependency inside the status keeps its arrow, while the one leaving it goes with the node it pointed at. + assert "GEN_1 --> GEN_2" in output + assert "CORE_1 -->" not in output + + # Nothing is borrowed, so a status scope never produces the dashed boundary a key scope does. + assert "external" not in output + + +def testGraphScopedToAnEmptyStatusIsAnAnswer(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + A status nothing carries is a true answer rather than a mistake, unlike a key nobody registered. + """ + + main(["new", "CORE", "App Shell"]) + capsys.readouterr() + + assert main(["graph", "done"]) == EXIT_OK + assert capsys.readouterr().out.startswith("graph TD\n") + + def testGraphRefusesAScopeItCannotRead(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: """ - A token that is neither an id nor a key would otherwise scope to nothing, which reads as an answer rather than a mistake. + A token that is none of the three shapes would otherwise scope to nothing, which reads as an answer rather than a mistake. """ assert main(["graph", "nonsense"]) == EXIT_USAGE @@ -876,6 +933,20 @@ def testGraphRefusesATokenBesideAFlag(inRepo: Path, capsys: pytest.CaptureFixtur assert main(["graph", "CORE", "--key", "GEN"]) == EXIT_USAGE assert "already scopes the graph" in capsys.readouterr().err + assert main(["graph", "CORE", "--status", "todo"]) == EXIT_USAGE + assert "already scopes the graph" in capsys.readouterr().err + + +def testGraphRefusesAStatusOutsideTheVocabulary(inRepo: Path) -> None: + """ + The status vocabulary is fixed, so a word outside it is a typo rather than an empty graph. + """ + + with pytest.raises(SystemExit) as excInfo: + main(["graph", "--status", "started"]) + + assert excInfo.value.code == EXIT_USAGE + def testGraphScopedToAKeyMarksNeighbors(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: """ @@ -1289,13 +1360,15 @@ def testFilterResolutionRefusesAnUnreadableToken() -> None: def testScopeResolutionReadsTheTokenShape() -> None: """ - One positional covers both scopes, because an id and a key cannot be confused for one another. + One positional covers all three scopes, because an id, a key, and a status cannot be confused for one another. """ - assert resolveGraphScope("CORE-14", None, None) == ("CORE-14", None) - assert resolveGraphScope("CORE", None, None) == (None, "CORE") - assert resolveGraphScope(None, "CORE-14", None) == ("CORE-14", None) - assert resolveGraphScope(None, None, None) == (None, None) + assert resolveGraphScope("CORE-14", None, None, None) == ("CORE-14", None, None) + assert resolveGraphScope("CORE", None, None, None) == (None, "CORE", None) + assert resolveGraphScope("todo", None, None, None) == (None, None, "todo") + assert resolveGraphScope(None, "CORE-14", None, None) == ("CORE-14", None, None) + assert resolveGraphScope(None, None, None, "done") == (None, None, "done") + assert resolveGraphScope(None, None, None, None) == (None, None, None) def testScopeResolutionRefusesATokenBesideAFlag() -> None: @@ -1304,16 +1377,19 @@ def testScopeResolutionRefusesATokenBesideAFlag() -> None: """ with pytest.raises(ConflictingArgumentsError): - resolveGraphScope("CORE", None, "GEN") + resolveGraphScope("CORE", None, "GEN", None) + + with pytest.raises(ConflictingArgumentsError): + resolveGraphScope("CORE", None, None, "todo") def testScopeResolutionRefusesAnUnreadableToken() -> None: """ - A scope that is neither an id nor a key would render an empty graph, which reads as an answer rather than a mistake. + A scope that is none of the three shapes would render an empty graph, which reads as an answer rather than a mistake. """ with pytest.raises(InvalidArgumentError): - resolveGraphScope("nonsense", None, None) + resolveGraphScope("nonsense", None, None, None) @pytest.mark.parametrize( diff --git a/tests/test_graph.py b/tests/test_graph.py index 0bbeaf9..38a3d00 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -8,7 +8,7 @@ import pytest -from docket.core.graph import Edge, Readiness, ResolvedGraph, dependencyContext, findCycles, readyTickets, resolveGraph, subgraphForId, subgraphForKey, ticketReadiness +from docket.core.graph import Edge, Readiness, ResolvedGraph, dependencyContext, findCycles, readyTickets, resolveGraph, subgraphForId, subgraphForKey, subgraphForStatus, ticketReadiness from docket.core.store import TicketSet from docket.core.ticket import Ticket @@ -150,6 +150,41 @@ def testSubgraphForKeyMarksCrossKeyNeighbors() -> None: assert "HEAD-1" not in scoped +def testSubgraphForStatusKeepsOnlyThatStatus() -> None: + """ + A status has no boundary worth drawing, so nothing outside it is borrowed and an edge survives only when both of its ends carry it. + """ + + ticketSet: TicketSet = buildSet( + ("CORE-1", []), + ("CORE-2", ["CORE-1"]), + ("CORE-3", ["CORE-2"]), + ) + ticketSet.tickets["CORE-1"].status = "done" + + scoped: ResolvedGraph = subgraphForStatus(resolveGraph(ticketSet), "todo") + + assert sorted(scoped.nodes) == ["CORE-2", "CORE-3"] + assert scoped.scope == "todo" + + # The dependency inside the status keeps its arrow, while the one leaving it goes with the node it pointed at. + assert scoped.edges == [Edge(fromId="CORE-2", toId="CORE-3")] + + # Nothing was borrowed, so the flag a key scope sets is never set here. + assert not any(node.isExternal for node in scoped.nodes.values()) + + +def testSubgraphForStatusMatchingNothingIsEmpty() -> None: + """ + A status nothing carries is a true answer rather than a mistake, so it scopes to an empty graph instead of raising. + """ + + scoped: ResolvedGraph = subgraphForStatus(resolveGraph(buildSet(("CORE-1", []))), "done") + + assert len(scoped) == 0 + assert scoped.edges == [] + + def testSubgraphOnlyKeepsEdgesWithBothEndsPresent() -> None: """ An edge leaving the scope would render as an arrow to nothing, so it is dropped. diff --git a/tests/test_server.py b/tests/test_server.py index 897d16e..5372b1d 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -549,6 +549,36 @@ def testGraphScopesToAKeyAndMarksNeighbors(inRepo: Path) -> None: assert "external" in payload["mermaid"] +def testGraphScopesToAStatus(inRepo: Path) -> None: + """ + Nothing outside the status is borrowed, so the result is the tickets carrying it and the edges between them. + """ + + callTool("create_ticket", {"key": "CORE", "title": "App Shell"}) + callTool("create_ticket", {"key": "GEN", "title": "Battlescape", "requires": ["CORE-1"]}) + callTool("set_status", {"id": "CORE-1", "status": "done"}) + + payload = callTool("graph", {"status": "todo"}) + + assert payload["scope"] == "todo" + assert payload["nodeCount"] == 1 + + # The edge left the scope with the node it pointed at, and nothing was borrowed to replace it. + assert "-->" not in payload["mermaid"] + assert "external" not in payload["mermaid"] + + +def testGraphRejectsAnUnknownStatus(inRepo: Path) -> None: + """ + The vocabulary is fixed, so an unrecognized status is refused rather than answered with an empty graph. + """ + + with pytest.raises(Exception) as excInfo: + callTool("graph", {"status": "blocked"}) + + assert "blocked" in str(excInfo.value) + + def testListKeysReturnsEveryRegisteredKey(inRepo: Path) -> None: """ An agent needs the full set of keys it may create under, with the descriptions that say which one fits. From b64c9f42a64222fb65a2b3ce05e7fe058ae868bd Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Wed, 12 Aug 2026 14:14:42 -0400 Subject: [PATCH 2/3] Richer graph cells --- src/docket/core/mermaid.py | 129 +++++++++++++++++++++++++++++--- tests/test_mermaid.py | 149 +++++++++++++++++++++++++++++-------- 2 files changed, 234 insertions(+), 44 deletions(-) diff --git a/src/docket/core/mermaid.py b/src/docket/core/mermaid.py index f656ce9..fa95b24 100644 --- a/src/docket/core/mermaid.py +++ b/src/docket/core/mermaid.py @@ -8,8 +8,9 @@ # MARK: Imports +import textwrap + from docket.core.graph import Edge, GraphNode, ResolvedGraph -from docket.core.ticket import STATUSES # MARK: Constants @@ -26,6 +27,32 @@ "done": "fill:#2d6a4f,color:#fff", } +# The delimiters that shape a node, so status survives in a renderer that ignores `classDef` entirely, the same way priority does by sitting in the label. Work in flight reads as a hexagon and finished work as a rounded rectangle, leaving the plain rectangle for what has not been started. +STATUS_SHAPES: dict[str, tuple[str, str]] = { + "todo": ("[", "]"), + "wip": ("{{", "}}"), + "done": ("(", ")"), +} + +# The shape a status nobody recognizes takes. It is the plain one, for the same reason such a node takes no class: inventing a shape for it would claim to know what it means. +DEFAULT_SHAPE: tuple[str, str] = ("[", "]") + +# The border a node takes from its priority, most urgent first, so urgency reads as weight and color and not only as the text in the label. A priority past the end of this takes the last entry, because the band's ceiling is configurable and this module cannot see it. +# +# The ramp drains toward neutral rather than cooling toward green, for two reasons. Green already means done here, so a low-priority todo would otherwise wear the color of finished work. And a red to green ramp is the one pairing a red-green colorblind reader cannot order at all, where draining to grey stays legible to everyone. Grey is also the honest end point, since the least urgent thing on the page wants no attention rather than a different kind of it. +# +# Width descends alongside the color, so the ordering survives being read in greyscale. +PRIORITY_STROKES: tuple[str, ...] = ( + "stroke:#ff6b6b,stroke-width:4px", + "stroke:#ff922b,stroke-width:3px", + "stroke:#ffd43b,stroke-width:2px", + "stroke:#adb5bd,stroke-width:2px", + "stroke:#6c757d,stroke-width:1px", +) + +# Where a long title wraps, in characters. Mermaid lays a label out on one line unless told otherwise, so one long title would stretch every node beside it. +LABEL_WRAP_WIDTH: int = 24 + # The class marking a node borrowed from another key, dashed so the boundary reads at a glance. EXTERNAL_CLASS: str = "external" EXTERNAL_STYLE: str = "fill:#212529,color:#adb5bd,stroke-dasharray: 3 3" @@ -73,14 +100,49 @@ def renderNode(node: GraphNode) -> str: """ Render one node declaration. - Priority is written into the label rather than carried by the class, so it survives in a renderer that ignores `classDef` entirely. + Status and priority are each said twice, once in a way a bare renderer keeps and once in a way it drops. The shape and the label survive anywhere, while the fill and the border are the richer reading for a renderer that honors `classDef`. So nothing a reader needs is only ever a color. node: The node to render. Returns the declaration line. """ - return f'{sanitizeId(node.id)}["{escapeLabel(node.id)} {escapeLabel(node.title)}
p{node.priority} {escapeLabel(node.status)}"]' + opening, closing = STATUS_SHAPES.get(node.status, DEFAULT_SHAPE) + + return f'{sanitizeId(node.id)}{opening}"{renderLabel(node)}"{closing}' + + +def renderLabel(node: GraphNode) -> str: + """ + Build the label text for one node. + + The id, the title, and the priority and status sit on their own lines rather than running together, since the id is what a reader scans for and a title beside it buries the id. + + node: The node to label. + + Returns the label, with its lines joined by mermaid's line break. + """ + + lines: list[str] = [escapeLabel(node.id), *wrapLabel(node.title), f"p{node.priority} {escapeLabel(node.status)}"] + + return "
".join(lines) + + +def wrapLabel(text: str) -> list[str]: + """ + Split a title into label lines at word boundaries. + + Wrapping happens before escaping, so an entity the escape introduces can neither be counted toward the width nor be broken across two lines. A single word longer than the width is left whole, because breaking an id or a path mid-word costs the reader more than the width does. + + text: The title to wrap. + + Returns the escaped lines, empty when there is no title to show. + """ + + if not text.strip(): + return [] + + return [escapeLabel(line) for line in textwrap.wrap(text, width=LABEL_WRAP_WIDTH, break_long_words=False, break_on_hyphens=False)] def renderEdge(edge: Edge) -> str: @@ -99,6 +161,8 @@ def renderStyles(graph: ResolvedGraph) -> list[str]: """ Render the class definitions and the class assignments for a graph. + A class carries the fill of a status and the border of a priority together, rather than a node taking one class for each. Combining them is what keeps every node to a single class, and it means only the combinations actually present are ever declared. + graph: The graph to style. Returns the style lines, empty when there is nothing to style. @@ -107,22 +171,22 @@ def renderStyles(graph: ResolvedGraph) -> list[str]: if not graph.nodes: return [] - lines: list[str] = [f"{INDENT}classDef {status} {STATUS_STYLES[status]}" for status in STATUSES] - # Group nodes by the class they take, with external winning over status so the boundary stays visible. + styles: dict[str, str] = {} grouped: dict[str, list[str]] = {} for node in graph.nodes.values(): - className: str = EXTERNAL_CLASS if node.isExternal else node.status - - # A status that arrived by hand-editing has no class, and is left unstyled rather than inventing one. - if className != EXTERNAL_CLASS and className not in STATUS_STYLES: + if node.isExternal: + className, style = EXTERNAL_CLASS, EXTERNAL_STYLE + elif node.status in STATUS_STYLES: + className, style = statusClassName(node.status, node.priority), f"{STATUS_STYLES[node.status]},{priorityStroke(node.priority)}" + else: + # A status that arrived by hand-editing has no class, and is left unstyled rather than inventing one. continue + styles[className] = style grouped.setdefault(className, []).append(sanitizeId(node.id)) - # Only declare the external class when something actually uses it. - if EXTERNAL_CLASS in grouped: - lines.append(f"{INDENT}classDef {EXTERNAL_CLASS} {EXTERNAL_STYLE}") + lines: list[str] = [f"{INDENT}classDef {className} {styles[className]}" for className in sorted(styles)] for className in sorted(grouped): lines.append(f"{INDENT}class {','.join(sorted(grouped[className]))} {className}") @@ -130,6 +194,47 @@ def renderStyles(graph: ResolvedGraph) -> list[str]: return lines +def statusClassName(status: str, priority: int) -> str: + """ + Name the class carrying one status and priority pairing. + + The priority is named by its band rather than by its number, so the class count stays bounded however high the configured ceiling goes. + + status: The status the class fills for. + priority: The priority the class borders for. + + Returns the class name. + """ + + return f"{status}P{priorityBand(priority)}" + + +def priorityStroke(priority: int) -> str: + """ + Select the border for one priority. + + priority: The priority to style. + + Returns the stroke declaration. + """ + + return PRIORITY_STROKES[priorityBand(priority)] + + +def priorityBand(priority: int) -> int: + """ + Clamp a priority to an index into `PRIORITY_STROKES`. + + Everything past the end shares the lightest border, since the configured ceiling can sit anywhere above it and a band nobody can distinguish is not worth a class of its own. + + priority: The priority to place. + + Returns the index. + """ + + return min(max(priority, 0), len(PRIORITY_STROKES) - 1) + + def sanitizeId(ticketId: str) -> str: """ Convert a ticket id into a mermaid node identifier. diff --git a/tests/test_mermaid.py b/tests/test_mermaid.py index d9b18bf..6279a44 100644 --- a/tests/test_mermaid.py +++ b/tests/test_mermaid.py @@ -52,19 +52,20 @@ def testTheDocumentedShapeIsProduced() -> None: expected: str = ( "graph TD\n" " subgraph CORE\n" - ' CORE_9["CORE-9 App shell
p0 done"]\n' - ' CORE_14["CORE-14 Skirmish setup
p1 todo"]\n' + ' CORE_9("CORE-9
App shell
p0 done")\n' + ' CORE_14["CORE-14
Skirmish setup
p1 todo"]\n' " end\n" " subgraph GEN\n" - ' GEN_3["GEN-3 Multi-layer battlescape
p2 todo"]\n' + ' GEN_3["GEN-3
Multi-layer battlescape
p2 todo"]\n' " end\n" " CORE_9 --> CORE_14\n" " GEN_3 --> CORE_14\n" - " classDef todo fill:#495057,color:#fff\n" - " classDef wip fill:#9a6700,color:#fff\n" - " classDef done fill:#2d6a4f,color:#fff\n" - " class CORE_9 done\n" - " class CORE_14,GEN_3 todo\n" + " classDef doneP0 fill:#2d6a4f,color:#fff,stroke:#ff6b6b,stroke-width:4px\n" + " classDef todoP1 fill:#495057,color:#fff,stroke:#ff922b,stroke-width:3px\n" + " classDef todoP2 fill:#495057,color:#fff,stroke:#ffd43b,stroke-width:2px\n" + " class CORE_9 doneP0\n" + " class CORE_14 todoP1\n" + " class GEN_3 todoP2\n" ) assert renderGraph(resolveGraph(ticketSet)) == expected @@ -77,45 +78,128 @@ def testTheHyphenatedIdStaysInTheLabel() -> None: output: str = renderGraph(resolveGraph(buildSet(("CORE-14", "Skirmish setup", "todo", 1, [])))) - assert 'CORE_14["CORE-14 Skirmish setup' in output + assert 'CORE_14["CORE-14
Skirmish setup' in output -def testPriorityIsInTheLabelNotTheStyling() -> None: +def testPriorityAndStatusSurviveWithoutClasses() -> None: """ - Priority must survive in a renderer that ignores classes entirely, so it lives in the label. + Both readings must survive a renderer that ignores classes entirely, so the label carries the priority and the shape carries the status. """ - output: str = renderGraph(resolveGraph(buildSet(("CORE-1", "A", "todo", 3, [])))) + output: str = renderGraph(resolveGraph(buildSet(("CORE-1", "A", "wip", 3, [])))) - assert "
p3 todo" in output + # Dropping every style line still leaves the priority written out and the status shaped. + assert "
p3 wip" in output + assert 'CORE_1{{"CORE-1' in output - # No style line mentions the priority, so a renderer that drops classes loses nothing. - for line in output.splitlines(): - if line.strip().startswith(("classDef", "class ")): - assert "p3" not in line +def testStatusPicksTheNodeShape() -> None: + """ + Status is said twice, and the shape is the half of it that no renderer can drop. + """ -def testOneSubgraphPerKey() -> None: + ticketSet: TicketSet = buildSet( + ("CORE-1", "A", "todo", 2, []), + ("CORE-2", "B", "wip", 2, []), + ("CORE-3", "C", "done", 2, []), + ) + + output: str = renderGraph(resolveGraph(ticketSet)) + + assert 'CORE_1["CORE-1
A
p2 todo"]' in output + assert 'CORE_2{{"CORE-2
B
p2 wip"}}' in output + assert 'CORE_3("CORE-3
C
p2 done")' in output + + +def testPriorityPicksTheBorderWeight() -> None: """ - Related work reads as a block, so each key gets its own subgraph and nothing else does. + Urgency reads as visual weight and color, so a lower priority number takes a heavier and more saturated border. """ - output: str = renderGraph(resolveGraph(buildSet(("CORE-1", "A", "todo", 2, []), ("GEN-1", "B", "todo", 2, [])))) + output: str = renderGraph(resolveGraph(buildSet(("CORE-1", "A", "todo", 0, []), ("CORE-2", "B", "todo", 2, [])))) - assert output.count("subgraph ") == 2 - assert output.count(" end\n") == 2 + assert "classDef todoP0 fill:#495057,color:#fff,stroke:#ff6b6b,stroke-width:4px" in output + assert "classDef todoP2 fill:#495057,color:#fff,stroke:#ffd43b,stroke-width:2px" in output + + +def testTheRampDrainsToNeutralRatherThanToGreen() -> None: + """ + Green already means done, so a low-priority todo must not wear the color of finished work. Draining to grey also stays ordered for a reader who cannot separate red from green. + """ + + ticketSet: TicketSet = buildSet(*((f"CORE-{priority + 1}", "A", "todo", priority, []) for priority in range(5))) + + output: str = renderGraph(resolveGraph(ticketSet)) + + # The last two bands are the neutrals the ramp ends on, and no band anywhere is a green. + assert "classDef todoP3 fill:#495057,color:#fff,stroke:#adb5bd,stroke-width:2px" in output + assert "classDef todoP4 fill:#495057,color:#fff,stroke:#6c757d,stroke-width:1px" in output + assert "stroke:#2d6a4f" not in output + + +def testAPriorityPastTheBandSharesTheLightestBorder() -> None: + """ + The configured ceiling can sit anywhere, so everything past the last band shares its border rather than inventing a class per number. + """ + + output: str = renderGraph(resolveGraph(buildSet(("CORE-1", "A", "todo", 4, []), ("CORE-2", "B", "todo", 9, [])))) + + # Both nodes land in the same class, so the priority they differ by shows only in the label. + assert " class CORE_1,CORE_2 todoP4\n" in output + assert "todoP9" not in output + assert "
p9 todo" in output -def testEveryStatusClassIsDeclared() -> None: +def testOnlyTheCombinationsPresentAreDeclared() -> None: """ - One `classDef` per status, whether or not every status is currently in use. + A class pairs a status with a priority, so declaring every pairing would declare mostly classes nothing takes. """ output: str = renderGraph(resolveGraph(buildSet(("CORE-1", "A", "todo", 2, [])))) - assert "classDef todo" in output - assert "classDef wip" in output - assert "classDef done" in output + assert "classDef todoP2" in output + assert output.count("classDef") == 1 + + +def testALongTitleWrapsAcrossLines() -> None: + """ + Mermaid lays a label out on one line, so one long title would stretch every node beside it. + """ + + output: str = renderGraph(resolveGraph(buildSet(("CORE-1", "Key Removal Checks Usage Outside the Lock", "todo", 2, [])))) + + assert 'CORE_1["CORE-1
Key Removal Checks Usage
Outside the Lock
p2 todo"]' in output + + +def testALongWordIsNotBroken() -> None: + """ + Breaking an id or a path mid-word costs the reader more than the width does. + """ + + output: str = renderGraph(resolveGraph(buildSet(("CORE-1", "Supercalifragilisticexpialidocious", "todo", 2, [])))) + + assert "Supercalifragilisticexpialidocious" in output + + +def testAnEmptyTitleLeavesNoBlankLine() -> None: + """ + A title nobody wrote produces no line at all, rather than a gap between the id and the meta line. + """ + + output: str = renderGraph(resolveGraph(buildSet(("CORE-1", "", "todo", 2, [])))) + + assert 'CORE_1["CORE-1
p2 todo"]' in output + + +def testOneSubgraphPerKey() -> None: + """ + Related work reads as a block, so each key gets its own subgraph and nothing else does. + """ + + output: str = renderGraph(resolveGraph(buildSet(("CORE-1", "A", "todo", 2, []), ("GEN-1", "B", "todo", 2, [])))) + + assert output.count("subgraph ") == 2 + assert output.count(" end\n") == 2 def testNodesAreGroupedIntoOneClassLine() -> None: @@ -131,8 +215,8 @@ def testNodesAreGroupedIntoOneClassLine() -> None: output: str = renderGraph(resolveGraph(ticketSet)) - assert " class CORE_1,CORE_2 todo\n" in output - assert " class CORE_3 done\n" in output + assert " class CORE_1,CORE_2 todoP2\n" in output + assert " class CORE_3 doneP2\n" in output def testExternalNodesAreStyledSeparately() -> None: @@ -149,7 +233,7 @@ def testExternalNodesAreStyledSeparately() -> None: assert "classDef external" in output assert " class CORE_9 external\n" in output - assert " class GEN_1 todo\n" in output + assert " class GEN_1 todoP2\n" in output # The borrowed node keeps its own key's subgraph, so the reader still sees where it came from. assert "subgraph CORE" in output @@ -178,7 +262,8 @@ def testAnUnrecognizedStatusIsLeftUnstyled() -> None: output: str = renderGraph(resolveGraph(buildSet(("CORE-1", "A", "blocked", 2, [])))) - assert 'CORE_1["CORE-1 A
p2 blocked"]' in output + # It takes the plain shape too, since inventing one would claim to know what the status means. + assert 'CORE_1["CORE-1
A
p2 blocked"]' in output assert "class CORE_1" not in output @@ -189,7 +274,7 @@ def testLabelsEscapeQuotesAndAngleBrackets() -> None: output: str = renderGraph(resolveGraph(buildSet(("CORE-1", 'The "big" ', "todo", 2, [])))) - assert 'CORE_1["CORE-1 The #quot;big#quot; <fix>
p2 todo"]' in output + assert 'CORE_1["CORE-1
The #quot;big#quot; <fix>
p2 todo"]' in output def testAmpersandIsEscapedOnlyOnce() -> None: From c0e3897625a163b2cb90c4edddda2123c8bfd2da Mon Sep 17 00:00:00 2001 From: Brody Childs Date: Wed, 12 Aug 2026 14:14:53 -0400 Subject: [PATCH 3/3] Finished FEAT-17 --- docs/tickets/{todo => done}/FEAT-17_addStatusToGraphScope.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/tickets/{todo => done}/FEAT-17_addStatusToGraphScope.md (96%) diff --git a/docs/tickets/todo/FEAT-17_addStatusToGraphScope.md b/docs/tickets/done/FEAT-17_addStatusToGraphScope.md similarity index 96% rename from docs/tickets/todo/FEAT-17_addStatusToGraphScope.md rename to docs/tickets/done/FEAT-17_addStatusToGraphScope.md index 4eea76b..5b45775 100644 --- a/docs/tickets/todo/FEAT-17_addStatusToGraphScope.md +++ b/docs/tickets/done/FEAT-17_addStatusToGraphScope.md @@ -1,7 +1,7 @@ --- id: FEAT-17 title: Add Status to Graph Scope -status: todo +status: done priority: 1 requires: [] metadata: {}