The Graph Style Guide
The Graph Style Guide: RuboCop for plans. Cops with thresholds run against any orchestrator's graph - depth, fan-in, orphans, and the style rule I care most about: fan-ins of two or more should NAME their dependencies, because a join you can't name is a join you don't understand.
Developer Experience
Round 7
Sandi Metz
exit 0
bundle exec ruby examples/graph_style.rb
a real captured run
GRAPH STYLE GUIDE (4 cops)
tidy plan: no offenses
messy plan: 4 offense(s)
Graph/MaxDepth: present sits 6 deep (limit 4)
(every level is latency and a failure domain)
Graph/MaxFanIn: funnel joins 4 (limit 3)
(wide joins own too many failure modes)
Graph/NoOrphans: stray touches nothing and is touched by nothing
(unconnected tasks are in the wrong plan or missing an edge)
Graph/NamedFanIns: funnel joins 4 but 4 edge(s) are unnamed - use needs:
(a join you can't name is a join you don't understand)
style guides work because they argue once, in a config file,
instead of every review. these thresholds are this team's taste -
yours may differ. that they're WRITTEN DOWN is the feature.
source
# frozen_string_literal: true # The Graph Style Guide: RuboCop for plans. Cops with thresholds run # against any orchestrator's graph - depth, fan-in, orphans, and the # style rule I care most about: fan-ins of two or more should NAME # their dependencies, because a join you can't name is a join you # don't understand. # # bundle exec ruby examples/graph_style.rb # # Runs offline; lints a tidy plan and a messy one. require class="s">"bundler/setup" require class="s">"agentic" STYLE = { class="s">"Graph/MaxDepth" => {limit: 4, why: class="s">"every level is latency and a failure domain"}, class="s">"Graph/MaxFanIn" => {limit: 3, why: class="s">"wide joins own too many failure modes"}, class="s">"Graph/NoOrphans" => {why: class="s">"unconnected tasks are in the wrong plan or missing an edge"}, class="s">"Graph/NamedFanIns" => {min_to_name: 2, why: class="s">"a join you can't name is a join you don't understand"} }.freeze def lint(graph, style) names = graph[class="y">:tasks].transform_values(&class="y">:description) stats = graph[class="y">:stats] offenses = [] if stats[class="y">:max_depth] > style[class="s">"Graph/MaxDepth"][class="y">:limit] deepest = stats[class="y">:depth].max_by { |_, d| d }.first offenses << [class="s">"Graph/MaxDepth", class="s">"#{names[deepest]} sits #{stats[class="y">:max_depth]} deep (limit #{style["Graph/MaxDepthclass="s">"][class="y">:limit]})"] end graph[class="y">:dependencies].each do |id, deps| if deps.size > style[class="s">"Graph/MaxFanIn"][class="y">:limit] offenses << [class="s">"Graph/MaxFanIn", class="s">"#{names[id]} joins #{deps.size} (limit #{style["Graph/MaxFanInclass="s">"][class="y">:limit]})"] end end graph[class="y">:dependencies].each do |id, deps| if deps.empty? && graph[class="y">:edges].none? { |e| e[class="y">:from] == id } && graph[class="y">:tasks].size > 1 offenses << [class="s">"Graph/NoOrphans", class="s">"#{names[id]} touches nothing and is touched by nothing"] end end graph[class="y">:dependencies].each do |id, deps| next if deps.size < style[class="s">"Graph/NamedFanIns"][class="y">:min_to_name] unnamed = graph[class="y">:edges].count { |e| e[class="y">:to] == id && e[class="y">:label].nil? } if unnamed.positive? offenses << [class="s">"Graph/NamedFanIns", class="s">"#{names[id]} joins #{deps.size} but #{unnamed} edge(s) are unnamed - use needs:"] end end offenses end def step(name) Agentic:class="y">:Task.new(description: name, agent_spec: {class="s">"name" => name, class="s">"instructions" => class="s">"work"}) end # --- a tidy plan ------------------------------------------------------------ tidy = Agentic:class="y">:PlanOrchestrator.new a = step(class="s">"fetch users") b = step(class="s">"fetch orders") c = step(class="s">"merge report") tidy.add_task(a) tidy.add_task(b) tidy.add_task(c, needs: {users: a, orders: b}) # --- a messy one -------------------------------------------------------------- messy = Agentic:class="y">:PlanOrchestrator.new sources = 4.times.map { |i| step(class="s">"source-#{i}") } funnel = step(class="s">"funnel") steps = %w[polish buff shine present].map { |n| step(n) } stray = step(class="s">"stray") sources.each { |t| messy.add_task(t) } messy.add_task(funnel, sources) previous = funnel steps.each { |t| messy.add_task(t, [previous]) && (previous = t) } messy.add_task(stray) puts class="s">"GRAPH STYLE GUIDE (#{STYLE.size} cops)" {class="s">"tidy plan" => tidy, class="s">"messy plan" => messy}.each do |label, orchestrator| offenses = lint(orchestrator.graph, STYLE) puts puts class="s">" #{label}: #{offenses.empty? ? "no offensesclass="s">" : "#{offenses.size} offense(s)class="s">"}" offenses.each do |cop, message| puts class="s">" #{cop}: #{message}" puts class="s">" (#{STYLE[cop][class="y">:why]})" end end puts puts class="s">"style guides work because they argue once, in a config file," puts class="s">"instead of every review. these thresholds are this team's taste -" puts class="s">"yours may differ. that they're WRITTEN DOWN is the feature."