okstra 0.148.1 → 0.150.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/project-structure-overview.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/translator-worker.md +67 -0
- package/runtime/bin/okstra-render-final-report.py +0 -11
- package/runtime/bin/okstra-render-report-views.py +20 -0
- package/runtime/bin/okstra-report-translate.py +158 -0
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
- package/runtime/prompts/lead/report-writer.md +14 -13
- package/runtime/prompts/lead/team-contract.md +2 -2
- package/runtime/prompts/profiles/project-analysis.md +18 -0
- package/runtime/prompts/profiles/release-handoff.md +3 -0
- package/runtime/prompts/profiles/requirements-discovery.md +7 -0
- package/runtime/prompts/wizard/prompts.ko.json +17 -1
- package/runtime/python/okstra_ctl/analysis_inputs.py +24 -9
- package/runtime/python/okstra_ctl/analysis_packet.py +23 -1
- package/runtime/python/okstra_ctl/clarification_items.py +241 -44
- package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +2 -2
- package/runtime/python/okstra_ctl/dispatch_state.py +12 -1
- package/runtime/python/okstra_ctl/final_report_paths.py +22 -1
- package/runtime/python/okstra_ctl/i18n.py +12 -7
- package/runtime/python/okstra_ctl/render_final_report.py +18 -17
- package/runtime/python/okstra_ctl/report_html/common.py +77 -47
- package/runtime/python/okstra_ctl/report_html/filters.py +63 -30
- package/runtime/python/okstra_ctl/report_html/models.py +15 -0
- package/runtime/python/okstra_ctl/report_html/render.py +86 -5
- package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +14 -4
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +3 -3
- package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +5 -3
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -7
- package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +5 -9
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +2 -5
- package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +1 -2
- package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +90 -4
- package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +1 -8
- package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +6 -4
- package/runtime/python/okstra_ctl/report_html/visualizations.py +146 -11
- package/runtime/python/okstra_ctl/report_translation.py +440 -0
- package/runtime/python/okstra_ctl/report_view_artifacts.py +5 -0
- package/runtime/python/okstra_ctl/report_views.py +23 -9
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/time_report.py +2 -2
- package/runtime/python/okstra_ctl/usage_report.py +2 -2
- package/runtime/python/okstra_ctl/user_response.py +11 -6
- package/runtime/python/okstra_ctl/wizard.py +100 -25
- package/runtime/python/okstra_ctl/worker_liveness.py +130 -36
- package/runtime/schemas/final-report-v2.0.schema.json +229 -1
- package/runtime/templates/reports/final-report.template.md +55 -0
- package/runtime/templates/reports/html/assets/base.css +59 -9
- package/runtime/templates/reports/html/base.template.html +23 -44
- package/runtime/templates/reports/html/i18n/en.json +395 -0
- package/runtime/templates/reports/html/i18n/ko.json +395 -0
- package/runtime/templates/reports/html/macros/forms.html +20 -20
- package/runtime/templates/reports/html/macros/layout.html +18 -5
- package/runtime/templates/reports/html/macros/visualizations.html +7 -5
- package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +30 -15
- package/runtime/templates/reports/html/tasks/error-analysis.template.html +22 -15
- package/runtime/templates/reports/html/tasks/feature-analysis.template.html +35 -15
- package/runtime/templates/reports/html/tasks/final-verification.template.html +21 -14
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +78 -19
- package/runtime/templates/reports/html/tasks/implementation.template.html +34 -16
- package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +11 -11
- package/runtime/templates/reports/html/tasks/project-analysis.template.html +65 -25
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +28 -14
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +35 -15
- package/runtime/templates/reports/report.js +8 -5
- package/runtime/validators/validate-report-views.py +1 -1
- package/runtime/validators/validate-run.py +28 -31
- package/runtime/validators/validate_analysis_report.py +36 -0
- package/src/cli-registry.mjs +11 -0
- package/src/commands/inspect/worker-liveness.mjs +9 -7
- package/src/commands/report/translate.mjs +31 -0
- package/src/lib/helper-scripts.mjs +1 -0
- package/runtime/templates/reports/i18n/ko.json +0 -273
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import re
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
|
|
7
|
-
from ..common import
|
|
7
|
+
from ..common import evidence_index
|
|
8
8
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
9
9
|
from ..visualizations import stage_map_figure
|
|
10
10
|
|
|
@@ -46,7 +46,7 @@ def plan_approval_state(data: dict) -> PlanApprovalState | None:
|
|
|
46
46
|
if row.get("blocks") == "approval"
|
|
47
47
|
and row.get("status") in {"open", "answered"}
|
|
48
48
|
)
|
|
49
|
-
reason = f"
|
|
49
|
+
reason = f"{len(blockers)} approval blocker(s) unresolved" if blockers else ""
|
|
50
50
|
return PlanApprovalState(
|
|
51
51
|
names,
|
|
52
52
|
resolve_recommended_option(recommended, names),
|
|
@@ -78,8 +78,6 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
|
|
|
78
78
|
planning = data["implementationPlanning"]
|
|
79
79
|
figure = _stage_figure(planning)
|
|
80
80
|
approval = plan_approval_state(data)
|
|
81
|
-
audit = audit_context(data)
|
|
82
|
-
audit["planBodyVerification"] = planning["planBodyVerification"]
|
|
83
81
|
context = {
|
|
84
82
|
"humanSummary": data["humanSummary"],
|
|
85
83
|
"planning": planning,
|
|
@@ -93,7 +91,6 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
|
|
|
93
91
|
and row.get("status") in {"open", "answered"}
|
|
94
92
|
],
|
|
95
93
|
"evidenceIndex": evidence_index(data),
|
|
96
|
-
"audit": audit,
|
|
97
94
|
}
|
|
98
95
|
return HumanReportView(
|
|
99
96
|
"implementation-planning",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Human-first improvement-discovery view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import
|
|
4
|
+
from ..common import evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualNode
|
|
6
6
|
from ..visualizations import matrix_figure
|
|
7
7
|
|
|
@@ -33,7 +33,6 @@ def build_improvement_discovery_view(data: dict) -> HumanReportView:
|
|
|
33
33
|
"narrative": improvement["userNarrative"],
|
|
34
34
|
"matrixFigure": figure,
|
|
35
35
|
"evidenceIndex": evidence_index(data),
|
|
36
|
-
"audit": audit_context(data),
|
|
37
36
|
}
|
|
38
37
|
return HumanReportView(
|
|
39
38
|
"improvement-discovery",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""Human-first project-analysis view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import
|
|
4
|
+
from ..common import evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
|
-
from ..visualizations import dependency_figure
|
|
6
|
+
from ..visualizations import dependency_figure, infrastructure_figure, workflow_figure
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def _component_nodes(project: dict) -> tuple[VisualNode, ...]:
|
|
@@ -14,6 +14,7 @@ def _component_nodes(project: dict) -> tuple[VisualNode, ...]:
|
|
|
14
14
|
group=(row.get("paths") or ["project"])[0].split("/", 1)[0],
|
|
15
15
|
status="stable",
|
|
16
16
|
detail=row["responsibility"],
|
|
17
|
+
paths=tuple(row.get("paths") or ()),
|
|
17
18
|
)
|
|
18
19
|
for row in project.get("components", [])
|
|
19
20
|
)
|
|
@@ -31,6 +32,88 @@ def _dependency_edges(project: dict) -> tuple[VisualEdge, ...]:
|
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def _infrastructure_figure(project: dict):
|
|
36
|
+
"""The components that hold a boundary, and what sits on the far side.
|
|
37
|
+
|
|
38
|
+
The dependency graph answers "what calls what inside the code". Where the
|
|
39
|
+
data lives and which outside service the code depends on is a different
|
|
40
|
+
question with different nodes, and reading it off a component graph means
|
|
41
|
+
reconstructing it from adapter paths.
|
|
42
|
+
"""
|
|
43
|
+
components = {row["id"]: row for row in project.get("components", [])}
|
|
44
|
+
nodes: list[VisualNode] = []
|
|
45
|
+
edges: list[VisualEdge] = []
|
|
46
|
+
seen: set[str] = set()
|
|
47
|
+
|
|
48
|
+
def owner_node(component_id: str) -> str | None:
|
|
49
|
+
row = components.get(component_id)
|
|
50
|
+
if row is None:
|
|
51
|
+
return None
|
|
52
|
+
if row["id"] not in seen:
|
|
53
|
+
seen.add(row["id"])
|
|
54
|
+
nodes.append(
|
|
55
|
+
VisualNode(row["id"], row["name"], "component", "stable", row["responsibility"], note="Component")
|
|
56
|
+
)
|
|
57
|
+
return row["id"]
|
|
58
|
+
|
|
59
|
+
for index, row in enumerate(project.get("dataStores", []), start=1):
|
|
60
|
+
store_id = f"store-{index}"
|
|
61
|
+
nodes.append(
|
|
62
|
+
VisualNode(store_id, row["name"], "store", "store", row.get("readBoundary", ""), note="Data store")
|
|
63
|
+
)
|
|
64
|
+
source = owner_node(row.get("ownerComponentId", ""))
|
|
65
|
+
if source:
|
|
66
|
+
edges.append(VisualEdge(source, store_id, "reads · writes", "storage"))
|
|
67
|
+
for index, row in enumerate(project.get("externalSystems", []), start=1):
|
|
68
|
+
system_id = f"external-{index}"
|
|
69
|
+
nodes.append(
|
|
70
|
+
VisualNode(system_id, row["name"], "external", "external", row.get("direction", ""), note="External system")
|
|
71
|
+
)
|
|
72
|
+
edges.append(VisualEdge(row.get("adapter", "adapter"), system_id, row.get("direction", ""), "external"))
|
|
73
|
+
if row.get("adapter") and row["adapter"] not in seen:
|
|
74
|
+
seen.add(row["adapter"])
|
|
75
|
+
nodes.append(
|
|
76
|
+
VisualNode(row["adapter"], row["adapter"], "adapter", "stable", "Adapter", note="Adapter")
|
|
77
|
+
)
|
|
78
|
+
return infrastructure_figure(
|
|
79
|
+
nodes=tuple(nodes), edges=tuple(edges), title="Infrastructure and boundaries"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _workflow_figure(project: dict):
|
|
84
|
+
"""Each workflow's steps, as the path a request takes between components."""
|
|
85
|
+
components = {row["id"]: row for row in project.get("components", [])}
|
|
86
|
+
nodes: list[VisualNode] = []
|
|
87
|
+
edges: list[VisualEdge] = []
|
|
88
|
+
seen: set[str] = set()
|
|
89
|
+
for flow in project.get("workflows", []):
|
|
90
|
+
steps = sorted(flow.get("steps", []), key=lambda step: step.get("order", 0))
|
|
91
|
+
for step in steps:
|
|
92
|
+
component_id = step.get("componentId", "")
|
|
93
|
+
if component_id and component_id not in seen:
|
|
94
|
+
seen.add(component_id)
|
|
95
|
+
row = components.get(component_id, {})
|
|
96
|
+
nodes.append(
|
|
97
|
+
VisualNode(
|
|
98
|
+
component_id,
|
|
99
|
+
row.get("name", component_id),
|
|
100
|
+
"component",
|
|
101
|
+
"stable",
|
|
102
|
+
step.get("action", ""),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
for first, second in zip(steps, steps[1:]):
|
|
106
|
+
edges.append(
|
|
107
|
+
VisualEdge(
|
|
108
|
+
first.get("componentId", ""),
|
|
109
|
+
second.get("componentId", ""),
|
|
110
|
+
second.get("action", "next"),
|
|
111
|
+
"workflow",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return workflow_figure(nodes=tuple(nodes), edges=tuple(edges), title="Workflows")
|
|
115
|
+
|
|
116
|
+
|
|
34
117
|
def build_project_analysis_view(data: dict) -> HumanReportView:
|
|
35
118
|
project = data["projectAnalysis"]
|
|
36
119
|
architecture = dependency_figure(
|
|
@@ -38,17 +121,20 @@ def build_project_analysis_view(data: dict) -> HumanReportView:
|
|
|
38
121
|
edges=_dependency_edges(project),
|
|
39
122
|
title="Project architecture and dependencies",
|
|
40
123
|
)
|
|
124
|
+
infrastructure = _infrastructure_figure(project)
|
|
125
|
+
workflows = _workflow_figure(project) if project.get("workflows") else None
|
|
41
126
|
context = {
|
|
42
127
|
"humanSummary": data["humanSummary"],
|
|
43
128
|
"project": project,
|
|
129
|
+
"infrastructureFigure": infrastructure,
|
|
130
|
+
"workflowFigure": workflows,
|
|
44
131
|
"narrative": project["userNarrative"],
|
|
45
132
|
"architectureFigure": architecture,
|
|
46
133
|
"evidenceIndex": evidence_index(data),
|
|
47
|
-
"audit": audit_context(data),
|
|
48
134
|
}
|
|
49
135
|
return HumanReportView(
|
|
50
136
|
task_type="project-analysis",
|
|
51
137
|
template_name="html/tasks/project-analysis.template.html",
|
|
52
138
|
context=context,
|
|
53
|
-
figures=(architecture,),
|
|
139
|
+
figures=tuple(f for f in (architecture, infrastructure, workflows) if f),
|
|
54
140
|
)
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
from urllib.parse import urlparse
|
|
5
5
|
|
|
6
|
-
from ..common import
|
|
6
|
+
from ..common import evidence_index
|
|
7
7
|
from ..models import HumanReportView, VisualNode
|
|
8
8
|
from ..visualizations import timeline_figure
|
|
9
9
|
|
|
@@ -31,12 +31,6 @@ def build_release_handoff_view(data: dict) -> HumanReportView:
|
|
|
31
31
|
figure = timeline_figure(
|
|
32
32
|
events=_timeline_nodes(handoff), title="Release handoff timeline"
|
|
33
33
|
)
|
|
34
|
-
audit = audit_context(data)
|
|
35
|
-
audit["deliveryEvidence"] = {
|
|
36
|
-
"executedCommands": handoff["executedCommands"],
|
|
37
|
-
"commits": handoff["commitList"],
|
|
38
|
-
"sourceVerification": handoff["sourceVerificationReport"],
|
|
39
|
-
}
|
|
40
34
|
context = {
|
|
41
35
|
"humanSummary": data["humanSummary"],
|
|
42
36
|
"handoff": handoff,
|
|
@@ -44,7 +38,6 @@ def build_release_handoff_view(data: dict) -> HumanReportView:
|
|
|
44
38
|
"handoffFigure": figure,
|
|
45
39
|
"prUrl": _safe_http_url(handoff["pullRequestOutcome"].get("url", "")),
|
|
46
40
|
"evidenceIndex": evidence_index(data),
|
|
47
|
-
"audit": audit,
|
|
48
41
|
}
|
|
49
42
|
return HumanReportView(
|
|
50
43
|
"release-handoff",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Human-first requirements-discovery view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import
|
|
4
|
+
from ..common import evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
6
|
from ..visualizations import decision_flow_figure
|
|
7
7
|
|
|
@@ -15,6 +15,7 @@ def _routing_figure(data: dict, requirements: dict):
|
|
|
15
15
|
"current",
|
|
16
16
|
"active",
|
|
17
17
|
requirements["systemInterpretation"],
|
|
18
|
+
note="This task",
|
|
18
19
|
)
|
|
19
20
|
}
|
|
20
21
|
edges = []
|
|
@@ -22,10 +23,12 @@ def _routing_figure(data: dict, requirements: dict):
|
|
|
22
23
|
source = row["fromTaskKey"]
|
|
23
24
|
target = row["toTaskKey"]
|
|
24
25
|
nodes.setdefault(
|
|
25
|
-
source,
|
|
26
|
+
source,
|
|
27
|
+
VisualNode(source, source, "current", "active", row["relation"], note="This task"),
|
|
26
28
|
)
|
|
27
29
|
nodes.setdefault(
|
|
28
|
-
target,
|
|
30
|
+
target,
|
|
31
|
+
VisualNode(target, target, "next", "pending", row["relation"], note="Next task"),
|
|
29
32
|
)
|
|
30
33
|
edges.append(VisualEdge(source, target, row["relation"], "dependency"))
|
|
31
34
|
return decision_flow_figure(
|
|
@@ -45,7 +48,6 @@ def build_requirements_discovery_view(data: dict) -> HumanReportView:
|
|
|
45
48
|
"routingFigure": figure,
|
|
46
49
|
"clarificationItems": data.get("clarificationItems", []),
|
|
47
50
|
"evidenceIndex": evidence_index(data),
|
|
48
|
-
"audit": audit_context(data),
|
|
49
51
|
}
|
|
50
52
|
return HumanReportView(
|
|
51
53
|
"requirements-discovery",
|
|
@@ -13,23 +13,124 @@ _COLUMN_WIDTH = 260
|
|
|
13
13
|
_ROW_HEIGHT = 100
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def
|
|
17
|
-
"""
|
|
18
|
-
|
|
16
|
+
def _group_columns(nodes: Sequence[VisualNode]) -> list[list[str]]:
|
|
17
|
+
"""One column per group, wrapping a tall group into extra columns.
|
|
18
|
+
|
|
19
|
+
This is the layout for a figure with no edges — a coverage or matrix view,
|
|
20
|
+
where the group is the only structure there is to show.
|
|
21
|
+
"""
|
|
19
22
|
counts: dict[str, int] = {}
|
|
20
23
|
column_offset: dict[str, int] = {}
|
|
21
24
|
offset = 0
|
|
22
|
-
for group in
|
|
25
|
+
for group in sorted({n.group for n in nodes}):
|
|
23
26
|
column_offset[group] = offset
|
|
24
27
|
size = sum(1 for n in nodes if n.group == group)
|
|
25
28
|
offset += max(1, -(-size // _ROWS_PER_COLUMN))
|
|
26
|
-
|
|
29
|
+
columns: list[list[str]] = [[] for _ in range(max(offset, 1))]
|
|
27
30
|
for node in nodes:
|
|
28
31
|
index = counts.get(node.group, 0)
|
|
29
32
|
counts[node.group] = index + 1
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
columns[column_offset[node.group] + index // _ROWS_PER_COLUMN].append(node.id)
|
|
34
|
+
return columns
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _depths(node_ids: list[str], edges: Sequence[VisualEdge]) -> dict[str, int]:
|
|
38
|
+
"""Longest path from a root, so a node sits past every one it depends on.
|
|
39
|
+
|
|
40
|
+
Relaxing until nothing moves settles the longest path; a cycle would relax
|
|
41
|
+
forever, so the pass count bounds it — a dependency graph that loops has no
|
|
42
|
+
correct layering anyway, and stopping leaves it readable rather than hung.
|
|
43
|
+
"""
|
|
44
|
+
depth = {node_id: 0 for node_id in node_ids}
|
|
45
|
+
links = [(e.source, e.target) for e in edges if e.source in depth and e.target in depth]
|
|
46
|
+
for _ in range(len(depth)):
|
|
47
|
+
settled = True
|
|
48
|
+
for source, target in links:
|
|
49
|
+
if depth[target] < depth[source] + 1:
|
|
50
|
+
depth[target] = depth[source] + 1
|
|
51
|
+
settled = False
|
|
52
|
+
if settled:
|
|
53
|
+
break
|
|
54
|
+
return depth
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_ORDERING_SWEEPS = 4
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _ordered_by_neighbours(
|
|
61
|
+
column: list[str], neighbours: list[str], links: list[tuple[str, str]], *, backward: bool
|
|
62
|
+
) -> list[str]:
|
|
63
|
+
"""Sort a column to sit opposite the nodes it connects to.
|
|
64
|
+
|
|
65
|
+
One direction is not enough: ordering a column by its parents can pull it
|
|
66
|
+
out of line with its children, so the sweeps alternate.
|
|
67
|
+
"""
|
|
68
|
+
rank = {node_id: index for index, node_id in enumerate(neighbours)}
|
|
69
|
+
original = {node_id: index for index, node_id in enumerate(column)}
|
|
70
|
+
|
|
71
|
+
def key(node_id: str) -> tuple[float, int]:
|
|
72
|
+
if backward:
|
|
73
|
+
related = [rank[t] for s, t in links if s == node_id and t in rank]
|
|
74
|
+
else:
|
|
75
|
+
related = [rank[s] for s, t in links if t == node_id and s in rank]
|
|
76
|
+
centre = sum(related) / len(related) if related else float(original[node_id])
|
|
77
|
+
return (centre, original[node_id])
|
|
78
|
+
|
|
79
|
+
return sorted(column, key=key)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _uncross(columns: list[list[str]], links: list[tuple[str, str]]) -> list[list[str]]:
|
|
83
|
+
"""Alternate forward and backward barycentre sweeps until they settle."""
|
|
84
|
+
for sweep in range(_ORDERING_SWEEPS):
|
|
85
|
+
before = [list(column) for column in columns]
|
|
86
|
+
if sweep % 2 == 0:
|
|
87
|
+
for index in range(1, len(columns)):
|
|
88
|
+
columns[index] = _ordered_by_neighbours(
|
|
89
|
+
columns[index], columns[index - 1], links, backward=False
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
for index in range(len(columns) - 2, -1, -1):
|
|
93
|
+
columns[index] = _ordered_by_neighbours(
|
|
94
|
+
columns[index], columns[index + 1], links, backward=True
|
|
95
|
+
)
|
|
96
|
+
if columns == before:
|
|
97
|
+
break
|
|
98
|
+
return columns
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _layer_columns(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> list[list[str]]:
|
|
102
|
+
"""Columns by dependency depth, with the unconnected nodes held back.
|
|
103
|
+
|
|
104
|
+
A node with no edge has no place in the dependency story, and letting it
|
|
105
|
+
take a slot pushed the nodes that do relate to each other apart. They get
|
|
106
|
+
their own columns after the graph.
|
|
107
|
+
"""
|
|
108
|
+
linked = {e.source for e in edges} | {e.target for e in edges}
|
|
109
|
+
connected = [n.id for n in nodes if n.id in linked]
|
|
110
|
+
loose = [n.id for n in nodes if n.id not in linked]
|
|
111
|
+
depth = _depths(connected, edges)
|
|
112
|
+
links = [(e.source, e.target) for e in edges if e.source in depth and e.target in depth]
|
|
113
|
+
|
|
114
|
+
columns: list[list[str]] = []
|
|
115
|
+
for level in range(max(depth.values(), default=-1) + 1):
|
|
116
|
+
columns.append([node_id for node_id in connected if depth[node_id] == level])
|
|
117
|
+
columns = _uncross(columns, links)
|
|
118
|
+
for index in range(0, len(loose), _ROWS_PER_COLUMN):
|
|
119
|
+
columns.append(loose[index:index + _ROWS_PER_COLUMN])
|
|
120
|
+
return columns or [[]]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _node_positions(
|
|
124
|
+
nodes: Sequence[VisualNode], edges: Sequence[VisualEdge] = ()
|
|
125
|
+
) -> dict[str, tuple[int, int]]:
|
|
126
|
+
columns = _layer_columns(nodes, edges) if edges else _group_columns(nodes)
|
|
127
|
+
positions: dict[str, tuple[int, int]] = {}
|
|
128
|
+
for column_index, column in enumerate(columns):
|
|
129
|
+
for row_index, node_id in enumerate(column):
|
|
130
|
+
positions[node_id] = (
|
|
131
|
+
50 + column_index * _COLUMN_WIDTH,
|
|
132
|
+
55 + row_index * _ROW_HEIGHT,
|
|
133
|
+
)
|
|
33
134
|
return positions
|
|
34
135
|
|
|
35
136
|
|
|
@@ -41,7 +142,7 @@ _ARROW_MARKER = (
|
|
|
41
142
|
|
|
42
143
|
|
|
43
144
|
def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> str:
|
|
44
|
-
positions = _node_positions(nodes)
|
|
145
|
+
positions = _node_positions(nodes, edges)
|
|
45
146
|
width = max((x for x, _ in positions.values()), default=50) + 230
|
|
46
147
|
height = max((y for _, y in positions.values()), default=55) + 90
|
|
47
148
|
parts = [f'<svg viewBox="0 0 {width} {height}" role="img" xmlns="http://www.w3.org/2000/svg">']
|
|
@@ -51,10 +152,28 @@ def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> s
|
|
|
51
152
|
continue
|
|
52
153
|
x1, y1 = positions[edge.source]
|
|
53
154
|
x2, y2 = positions[edge.target]
|
|
155
|
+
# Leave the source box on its right edge and arrive on the target's
|
|
156
|
+
# left, so a left-to-right layering reads as one direction of travel.
|
|
157
|
+
start_x, end_x = (x1 + 180, x2) if x2 > x1 else ((x1, x2 + 180) if x2 < x1 else (x1 + 90, x2 + 90))
|
|
158
|
+
start_y, end_y = y1 + 25, y2 + 25
|
|
159
|
+
span = abs(x2 - x1) // _COLUMN_WIDTH
|
|
160
|
+
if span > 1:
|
|
161
|
+
# An edge that skips a column would otherwise be drawn straight
|
|
162
|
+
# through the boxes standing in it. Arcing it clear of the band is
|
|
163
|
+
# what removes the crossings the layering itself cannot.
|
|
164
|
+
# Clamped so a long skip from the top row still arcs inside
|
|
165
|
+
# the canvas instead of being drawn off it.
|
|
166
|
+
lift = max(10, min(start_y, end_y) - 30 - 12 * span)
|
|
167
|
+
geometry = (
|
|
168
|
+
f'<path d="M {start_x} {start_y} Q {(start_x + end_x) // 2} {lift} {end_x} {end_y}" '
|
|
169
|
+
f'fill="none"'
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
geometry = f'<line x1="{start_x}" y1="{start_y}" x2="{end_x}" y2="{end_y}"'
|
|
54
173
|
parts.append(
|
|
55
174
|
f'<g class="edge-group"><title>{html.escape(edge.label)}</title>'
|
|
56
|
-
f'
|
|
57
|
-
f'
|
|
175
|
+
f'{geometry} class="edge edge-{html.escape(edge.kind)}" '
|
|
176
|
+
f'marker-end="url(#edge-arrow)" /></g>'
|
|
58
177
|
)
|
|
59
178
|
for node in nodes:
|
|
60
179
|
x, y = positions[node.id]
|
|
@@ -86,6 +205,22 @@ def dependency_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge
|
|
|
86
205
|
)
|
|
87
206
|
|
|
88
207
|
|
|
208
|
+
def infrastructure_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
|
|
209
|
+
return replace(
|
|
210
|
+
dependency_figure(nodes=nodes, edges=edges, title=title),
|
|
211
|
+
kind="infrastructure",
|
|
212
|
+
figure_id="infrastructure-graph",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def workflow_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
|
|
217
|
+
return replace(
|
|
218
|
+
dependency_figure(nodes=nodes, edges=edges, title=title),
|
|
219
|
+
kind="workflow",
|
|
220
|
+
figure_id="workflow-graph",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
89
224
|
def flow_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
|
|
90
225
|
return replace(dependency_figure(nodes=nodes, edges=edges, title=title), kind="flow", figure_id="flow-graph")
|
|
91
226
|
|