spolvero 0.3.0__py3-none-any.whl
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.
- spolvero/__init__.py +7 -0
- spolvero/builder.py +200 -0
- spolvero/cli.py +118 -0
- spolvero/constants.py +55 -0
- spolvero/engines/__init__.py +102 -0
- spolvero/engines/data_visualizer.py +56 -0
- spolvero/engines/package_only.py +28 -0
- spolvero/engines/visio_com.py +118 -0
- spolvero/engines/vsdx_template.py +251 -0
- spolvero/hygiene.py +205 -0
- spolvero/inspection.py +222 -0
- spolvero/ir.py +248 -0
- spolvero/models.py +19 -0
- spolvero/render.py +564 -0
- spolvero/reports.py +180 -0
- spolvero/schema.py +65 -0
- spolvero/schemas/__init__.py +2 -0
- spolvero/schemas/ir.schema.json +83 -0
- spolvero/schemas/manifest.schema.json +74 -0
- spolvero/schemas/topology.schema.json +85 -0
- spolvero/source.py +160 -0
- spolvero/templates/LICENSE.txt +42 -0
- spolvero/templates/minimal_flowchart.vsdx +0 -0
- spolvero/topology/__init__.py +27 -0
- spolvero/topology/model.py +275 -0
- spolvero/utils.py +57 -0
- spolvero/workbook.py +200 -0
- spolvero-0.3.0.dist-info/METADATA +106 -0
- spolvero-0.3.0.dist-info/RECORD +31 -0
- spolvero-0.3.0.dist-info/WHEEL +4 -0
- spolvero-0.3.0.dist-info/entry_points.txt +2 -0
spolvero/reports.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Markdown report generation for handoff packages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from spolvero.models import DiagramSource
|
|
9
|
+
from spolvero.utils import write_text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_template_notes(
|
|
13
|
+
path: Path,
|
|
14
|
+
diagram: DiagramSource,
|
|
15
|
+
ir: dict[str, Any],
|
|
16
|
+
target: str,
|
|
17
|
+
editability: str,
|
|
18
|
+
warnings: list[str],
|
|
19
|
+
) -> None:
|
|
20
|
+
containers = ir.get("containers", [])
|
|
21
|
+
lines = [
|
|
22
|
+
f"# Visio Template Notes: {diagram.id}",
|
|
23
|
+
"",
|
|
24
|
+
f"- Requested target: `{target}`",
|
|
25
|
+
f"- Current editability: `{editability}`",
|
|
26
|
+
f"- Source type: `{diagram.source_type}`",
|
|
27
|
+
f"- Nodes recognized: {len(ir.get('nodes', []))}",
|
|
28
|
+
f"- Edges recognized: {len(ir.get('edges', []))}",
|
|
29
|
+
f"- Containers/swimlanes: {len(containers)}",
|
|
30
|
+
"",
|
|
31
|
+
]
|
|
32
|
+
if containers:
|
|
33
|
+
container_labels = ", ".join(f"`{c.get('label', c['id'])}`" for c in containers)
|
|
34
|
+
lines.extend(
|
|
35
|
+
[
|
|
36
|
+
"## Recommended Visio Template",
|
|
37
|
+
"",
|
|
38
|
+
f"This diagram declares {len(containers)} container(s) ({container_labels}). When importing the generated Data Visualizer workbook into Visio, choose the **Cross-Functional Flowchart** (swimlanes) template — not Basic Flowchart — so the `Function` column maps to swimlanes.",
|
|
39
|
+
"",
|
|
40
|
+
]
|
|
41
|
+
)
|
|
42
|
+
else:
|
|
43
|
+
lines.extend(
|
|
44
|
+
[
|
|
45
|
+
"## Recommended Visio Template",
|
|
46
|
+
"",
|
|
47
|
+
"No containers were detected. Importing into Visio's **Basic Flowchart** template is appropriate.",
|
|
48
|
+
"",
|
|
49
|
+
]
|
|
50
|
+
)
|
|
51
|
+
lines.extend(
|
|
52
|
+
[
|
|
53
|
+
"## Native Visio Follow-Up",
|
|
54
|
+
"",
|
|
55
|
+
"Use the generated Data Visualizer workbook for the first Visio-native pass when available. For higher fidelity, map the normalized IR to organization-approved templates and stencils, then generate native `.vsdx` with Visio COM automation or a tested VSDX template mutation engine.",
|
|
56
|
+
"",
|
|
57
|
+
"## Warnings",
|
|
58
|
+
"",
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
if warnings:
|
|
62
|
+
lines.extend(f"- {warning}" for warning in warnings)
|
|
63
|
+
else:
|
|
64
|
+
lines.append("- None")
|
|
65
|
+
write_text(path, "\n".join(lines) + "\n")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_qa_report(
|
|
69
|
+
path: Path,
|
|
70
|
+
diagram: DiagramSource,
|
|
71
|
+
ir: dict[str, Any],
|
|
72
|
+
editability: str,
|
|
73
|
+
engine: str,
|
|
74
|
+
score: int,
|
|
75
|
+
warnings: list[str],
|
|
76
|
+
workbook_path: Path | None,
|
|
77
|
+
svg_path: Path,
|
|
78
|
+
) -> None:
|
|
79
|
+
source = diagram.source_file
|
|
80
|
+
workbook_line = f"- Data Visualizer workbook: `{workbook_path.name}`" if workbook_path else "- Data Visualizer workbook: not generated"
|
|
81
|
+
warnings_block = "\n".join(f"- {warning}" for warning in warnings) if warnings else "- None"
|
|
82
|
+
if warnings:
|
|
83
|
+
warning_summary = f"**{len(warnings)} warning(s) — review before sharing.** See the Warnings section below."
|
|
84
|
+
else:
|
|
85
|
+
warning_summary = "**No warnings.**"
|
|
86
|
+
content = f"""# Diagram QA Report: {diagram.id}
|
|
87
|
+
|
|
88
|
+
{warning_summary}
|
|
89
|
+
|
|
90
|
+
## Summary
|
|
91
|
+
|
|
92
|
+
- Source: `{source}`
|
|
93
|
+
- Preview: `{svg_path.name}`
|
|
94
|
+
{workbook_line}
|
|
95
|
+
- Editability: `{editability}`
|
|
96
|
+
- Engine: `{engine}`
|
|
97
|
+
- Conversion score: {score}/100
|
|
98
|
+
|
|
99
|
+
## Preserved
|
|
100
|
+
|
|
101
|
+
- Node labels: {len(ir.get("nodes", []))}/{len(ir.get("nodes", []))}
|
|
102
|
+
- Edges: {len(ir.get("edges", []))}/{len(ir.get("edges", []))}
|
|
103
|
+
- Containers/swimlanes: {len(ir.get("containers", []))}
|
|
104
|
+
- Source references: {"yes" if ir.get("nodes") else "not available"}
|
|
105
|
+
|
|
106
|
+
## Warnings
|
|
107
|
+
|
|
108
|
+
{warnings_block}
|
|
109
|
+
|
|
110
|
+
## Manual Review Checklist
|
|
111
|
+
|
|
112
|
+
- [ ] Confirm all node labels are readable.
|
|
113
|
+
- [ ] Confirm connector directions.
|
|
114
|
+
- [ ] Confirm decision branch labels.
|
|
115
|
+
- [ ] Confirm title block and document metadata.
|
|
116
|
+
- [ ] Confirm shape data is suitable before external sharing.
|
|
117
|
+
- [ ] Decide whether future edits belong in source or only in Visio.
|
|
118
|
+
"""
|
|
119
|
+
write_text(path, content)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def conversion_score(ir: dict[str, Any], editability: str, warnings: list[str]) -> int:
|
|
123
|
+
score = 100
|
|
124
|
+
if editability == "vector_reference":
|
|
125
|
+
score -= 25
|
|
126
|
+
if editability == "raster_reference":
|
|
127
|
+
score -= 40
|
|
128
|
+
if not ir.get("nodes"):
|
|
129
|
+
score -= 20
|
|
130
|
+
if not ir.get("edges") and ir.get("diagram_type") == "flowchart":
|
|
131
|
+
score -= 15
|
|
132
|
+
score -= min(30, len(warnings) * 5)
|
|
133
|
+
return max(0, score)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def write_package_readme(path: Path, input_path: Path, target: str, rows: list[tuple[dict[str, Any], int]]) -> None:
|
|
137
|
+
diagram_lines = []
|
|
138
|
+
for entry, score in rows:
|
|
139
|
+
first = entry.get("data_visualizer_workbook") or entry.get("preview_svg")
|
|
140
|
+
warnings = entry.get("warnings") or []
|
|
141
|
+
warning_text = " ".join(warnings) if warnings else "No warnings."
|
|
142
|
+
diagram_lines.append(
|
|
143
|
+
"\n".join(
|
|
144
|
+
[
|
|
145
|
+
f"### {entry['id']}: {entry['title']}",
|
|
146
|
+
"",
|
|
147
|
+
f"- Open first: `{first}`",
|
|
148
|
+
f"- Editability: `{entry['editability']}`",
|
|
149
|
+
f"- Engine: `{entry['conversion_engine']}`",
|
|
150
|
+
f"- QA score: {score}/100",
|
|
151
|
+
f"- Known limitations: {warning_text}",
|
|
152
|
+
]
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
content = f"""# Visio Handoff Package
|
|
156
|
+
|
|
157
|
+
## What Was Generated
|
|
158
|
+
|
|
159
|
+
This package was generated from `{input_path.name}` with target `{target}`. It preserves the original source, extracted diagram source, preview images, normalized IR, Visio handoff candidates, and QA reports.
|
|
160
|
+
|
|
161
|
+
## Which File To Open First
|
|
162
|
+
|
|
163
|
+
Start with the entries below, then review `qa/` before making final Visio edits.
|
|
164
|
+
|
|
165
|
+
{chr(10).join(diagram_lines)}
|
|
166
|
+
|
|
167
|
+
## Editability Declaration
|
|
168
|
+
|
|
169
|
+
Generated Visio workbooks are downstream handoff artifacts. The original docs-as-code source remains canonical unless the project explicitly chooses a Visio-first lifecycle.
|
|
170
|
+
|
|
171
|
+
## Manual Review Checklist
|
|
172
|
+
|
|
173
|
+
- [ ] Confirm all labels and connector directions.
|
|
174
|
+
- [ ] Review warnings in each QA report.
|
|
175
|
+
- [ ] Open the Data Visualizer workbook in Excel/Visio if present.
|
|
176
|
+
- [ ] Apply organization templates, stencils, title blocks, and approvals in Visio.
|
|
177
|
+
- [ ] Push durable content edits back to the source diagram when source remains canonical.
|
|
178
|
+
"""
|
|
179
|
+
write_text(path, content)
|
|
180
|
+
|
spolvero/schema.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Small schema loader and validator for generated artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SchemaValidationError(ValueError):
|
|
11
|
+
"""Raised when generated JSON does not match the bundled schema subset."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_schema(name: str) -> dict[str, Any]:
|
|
15
|
+
schema_path = files("spolvero.schemas").joinpath(name)
|
|
16
|
+
return json.loads(schema_path.read_text(encoding="utf-8"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_manifest(manifest: dict[str, Any]) -> None:
|
|
20
|
+
validate_with_schema(manifest, load_schema("manifest.schema.json"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_ir(ir: dict[str, Any]) -> None:
|
|
24
|
+
if ir.get("diagram_type") == "topology":
|
|
25
|
+
validate_with_schema(ir, load_schema("topology.schema.json"))
|
|
26
|
+
return
|
|
27
|
+
validate_with_schema(ir, load_schema("ir.schema.json"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_with_schema(value: Any, schema: dict[str, Any], path: str = "$") -> None:
|
|
31
|
+
expected_type = schema.get("type")
|
|
32
|
+
if expected_type:
|
|
33
|
+
_assert_type(value, expected_type, path)
|
|
34
|
+
|
|
35
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
36
|
+
raise SchemaValidationError(f"{path} must be one of {schema['enum']!r}")
|
|
37
|
+
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
for key in schema.get("required", []):
|
|
40
|
+
if key not in value:
|
|
41
|
+
raise SchemaValidationError(f"{path}.{key} is required")
|
|
42
|
+
properties = schema.get("properties", {})
|
|
43
|
+
for key, child_schema in properties.items():
|
|
44
|
+
if key in value:
|
|
45
|
+
validate_with_schema(value[key], child_schema, f"{path}.{key}")
|
|
46
|
+
|
|
47
|
+
if isinstance(value, list) and "items" in schema:
|
|
48
|
+
for index, item in enumerate(value):
|
|
49
|
+
validate_with_schema(item, schema["items"], f"{path}[{index}]")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _assert_type(value: Any, expected_type: str | list[str], path: str) -> None:
|
|
53
|
+
types = [expected_type] if isinstance(expected_type, str) else expected_type
|
|
54
|
+
checks = {
|
|
55
|
+
"object": lambda candidate: isinstance(candidate, dict),
|
|
56
|
+
"array": lambda candidate: isinstance(candidate, list),
|
|
57
|
+
"string": lambda candidate: isinstance(candidate, str),
|
|
58
|
+
"integer": lambda candidate: isinstance(candidate, int) and not isinstance(candidate, bool),
|
|
59
|
+
"number": lambda candidate: isinstance(candidate, int | float) and not isinstance(candidate, bool),
|
|
60
|
+
"boolean": lambda candidate: isinstance(candidate, bool),
|
|
61
|
+
"null": lambda candidate: candidate is None,
|
|
62
|
+
}
|
|
63
|
+
if not any(checks[type_name](value) for type_name in types):
|
|
64
|
+
raise SchemaValidationError(f"{path} expected {types!r}, got {type(value).__name__}")
|
|
65
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://example.invalid/spolvero/ir.schema.json",
|
|
4
|
+
"title": "Visio Handoff Normalized IR",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": [
|
|
7
|
+
"schema_version",
|
|
8
|
+
"id",
|
|
9
|
+
"title",
|
|
10
|
+
"diagram_type",
|
|
11
|
+
"orientation",
|
|
12
|
+
"pages",
|
|
13
|
+
"nodes",
|
|
14
|
+
"edges",
|
|
15
|
+
"containers",
|
|
16
|
+
"warnings"
|
|
17
|
+
],
|
|
18
|
+
"properties": {
|
|
19
|
+
"schema_version": { "type": "string" },
|
|
20
|
+
"id": { "type": "string" },
|
|
21
|
+
"title": { "type": "string" },
|
|
22
|
+
"diagram_type": { "type": "string", "enum": ["flowchart", "reference"] },
|
|
23
|
+
"orientation": { "type": "string" },
|
|
24
|
+
"pages": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"items": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"required": ["id", "title", "size"],
|
|
29
|
+
"properties": {
|
|
30
|
+
"id": { "type": "string" },
|
|
31
|
+
"title": { "type": "string" },
|
|
32
|
+
"size": { "type": "string" }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"nodes": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"items": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"required": ["id", "label", "shape_kind", "metadata"],
|
|
41
|
+
"properties": {
|
|
42
|
+
"id": { "type": "string" },
|
|
43
|
+
"label": { "type": "string" },
|
|
44
|
+
"shape_kind": { "type": "string" },
|
|
45
|
+
"container": { "type": "string" },
|
|
46
|
+
"metadata": { "type": "object" }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"edges": {
|
|
51
|
+
"type": "array",
|
|
52
|
+
"items": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"required": ["id", "from", "to", "label", "kind", "metadata"],
|
|
55
|
+
"properties": {
|
|
56
|
+
"id": { "type": "string" },
|
|
57
|
+
"from": { "type": "string" },
|
|
58
|
+
"to": { "type": "string" },
|
|
59
|
+
"label": { "type": "string" },
|
|
60
|
+
"kind": { "type": "string" },
|
|
61
|
+
"metadata": { "type": "object" }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"containers": {
|
|
66
|
+
"type": "array",
|
|
67
|
+
"items": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"required": ["id", "label", "kind"],
|
|
70
|
+
"properties": {
|
|
71
|
+
"id": { "type": "string" },
|
|
72
|
+
"label": { "type": "string" },
|
|
73
|
+
"kind": { "type": "string" }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"warnings": {
|
|
78
|
+
"type": "array",
|
|
79
|
+
"items": { "type": "string" }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://example.invalid/spolvero/manifest.schema.json",
|
|
4
|
+
"title": "Visio Handoff Package Manifest",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": [
|
|
7
|
+
"schema_version",
|
|
8
|
+
"skill",
|
|
9
|
+
"version",
|
|
10
|
+
"created_at",
|
|
11
|
+
"source_files",
|
|
12
|
+
"target",
|
|
13
|
+
"diagrams"
|
|
14
|
+
],
|
|
15
|
+
"properties": {
|
|
16
|
+
"schema_version": { "type": "string" },
|
|
17
|
+
"skill": { "type": "string" },
|
|
18
|
+
"version": { "type": "string" },
|
|
19
|
+
"created_at": { "type": "string" },
|
|
20
|
+
"source_files": {
|
|
21
|
+
"type": "array",
|
|
22
|
+
"items": { "type": ["string", "null"] }
|
|
23
|
+
},
|
|
24
|
+
"target": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"enum": ["package-only", "data-visualizer", "vsdx-template", "visio-com"]
|
|
27
|
+
},
|
|
28
|
+
"diagrams": {
|
|
29
|
+
"type": "array",
|
|
30
|
+
"items": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"required": [
|
|
33
|
+
"id",
|
|
34
|
+
"title",
|
|
35
|
+
"source_type",
|
|
36
|
+
"source",
|
|
37
|
+
"normalized_ir",
|
|
38
|
+
"output_vsdx",
|
|
39
|
+
"data_visualizer_workbook",
|
|
40
|
+
"preview_svg",
|
|
41
|
+
"preview_png",
|
|
42
|
+
"editability",
|
|
43
|
+
"conversion_engine",
|
|
44
|
+
"requested_engine",
|
|
45
|
+
"actual_engine",
|
|
46
|
+
"warnings"
|
|
47
|
+
],
|
|
48
|
+
"properties": {
|
|
49
|
+
"id": { "type": "string" },
|
|
50
|
+
"title": { "type": "string" },
|
|
51
|
+
"source_type": { "type": "string" },
|
|
52
|
+
"source": { "type": "string" },
|
|
53
|
+
"normalized_ir": { "type": "string" },
|
|
54
|
+
"output_vsdx": { "type": ["string", "null"] },
|
|
55
|
+
"data_visualizer_workbook": { "type": ["string", "null"] },
|
|
56
|
+
"preview_svg": { "type": "string" },
|
|
57
|
+
"preview_png": { "type": "string" },
|
|
58
|
+
"editability": {
|
|
59
|
+
"type": "string",
|
|
60
|
+
"enum": ["native", "semi_native", "vector_reference", "raster_reference"]
|
|
61
|
+
},
|
|
62
|
+
"conversion_engine": { "type": "string" },
|
|
63
|
+
"requested_engine": { "type": "string" },
|
|
64
|
+
"actual_engine": { "type": "string" },
|
|
65
|
+
"warnings": {
|
|
66
|
+
"type": "array",
|
|
67
|
+
"items": { "type": "string" }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://example.invalid/spolvero/topology.schema.json",
|
|
4
|
+
"title": "Visio Handoff Topology IR",
|
|
5
|
+
"description": "Normalized intermediate representation for network diagrams and Technical Reference Diagrams (TRDs).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"schema_version",
|
|
9
|
+
"id",
|
|
10
|
+
"title",
|
|
11
|
+
"diagram_type",
|
|
12
|
+
"nodes",
|
|
13
|
+
"edges",
|
|
14
|
+
"containers",
|
|
15
|
+
"warnings"
|
|
16
|
+
],
|
|
17
|
+
"properties": {
|
|
18
|
+
"schema_version": { "type": "string" },
|
|
19
|
+
"id": { "type": "string" },
|
|
20
|
+
"title": { "type": "string" },
|
|
21
|
+
"diagram_type": { "type": "string", "enum": ["topology"] },
|
|
22
|
+
"orientation": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"enum": ["left_to_right", "top_down", "unknown"]
|
|
25
|
+
},
|
|
26
|
+
"pages": {
|
|
27
|
+
"type": "array",
|
|
28
|
+
"items": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"required": ["id", "title"],
|
|
31
|
+
"properties": {
|
|
32
|
+
"id": { "type": "string" },
|
|
33
|
+
"title": { "type": "string" },
|
|
34
|
+
"size": { "type": "string" }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"nodes": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"items": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"required": ["id", "label", "kind"],
|
|
43
|
+
"properties": {
|
|
44
|
+
"id": { "type": "string" },
|
|
45
|
+
"label": { "type": "string" },
|
|
46
|
+
"kind": { "type": "string" },
|
|
47
|
+
"container": { "type": ["string", "null"] },
|
|
48
|
+
"metadata": { "type": "object" }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"edges": {
|
|
53
|
+
"type": "array",
|
|
54
|
+
"items": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"required": ["id", "from", "to"],
|
|
57
|
+
"properties": {
|
|
58
|
+
"id": { "type": "string" },
|
|
59
|
+
"from": { "type": "string" },
|
|
60
|
+
"to": { "type": "string" },
|
|
61
|
+
"label": { "type": "string" },
|
|
62
|
+
"kind": { "type": "string" },
|
|
63
|
+
"metadata": { "type": "object" }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"containers": {
|
|
68
|
+
"type": "array",
|
|
69
|
+
"items": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"required": ["id", "label", "kind"],
|
|
72
|
+
"properties": {
|
|
73
|
+
"id": { "type": "string" },
|
|
74
|
+
"label": { "type": "string" },
|
|
75
|
+
"kind": { "type": "string" },
|
|
76
|
+
"parent": { "type": ["string", "null"] }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"warnings": {
|
|
81
|
+
"type": "array",
|
|
82
|
+
"items": { "type": "string" }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
spolvero/source.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Input discovery and diagram source extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from spolvero.constants import DIAGRAM_LANGS
|
|
9
|
+
from spolvero.models import DiagramSource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def extract_diagrams(input_path: Path, title: str = "") -> list[DiagramSource]:
|
|
13
|
+
suffix = input_path.suffix.lower()
|
|
14
|
+
name = input_path.name.lower()
|
|
15
|
+
|
|
16
|
+
# Topology files: ``foo.topology.yaml`` / ``.yml`` / ``.json`` or any
|
|
17
|
+
# ``*.topology.*`` extension; also bare .topology files.
|
|
18
|
+
is_topology = (
|
|
19
|
+
".topology." in name
|
|
20
|
+
or name.endswith((".topology.yaml", ".topology.yml", ".topology.json"))
|
|
21
|
+
)
|
|
22
|
+
if is_topology:
|
|
23
|
+
text = input_path.read_text(encoding="utf-8", errors="replace")
|
|
24
|
+
fmt = "json" if name.endswith(".json") else "yaml"
|
|
25
|
+
return [
|
|
26
|
+
DiagramSource(
|
|
27
|
+
id="diagram-001",
|
|
28
|
+
title=title or input_path.stem.replace(".topology", ""),
|
|
29
|
+
language="topology",
|
|
30
|
+
source_type=f"topology.{fmt}",
|
|
31
|
+
text=text,
|
|
32
|
+
source_file=input_path.name,
|
|
33
|
+
origin_file=input_path.name,
|
|
34
|
+
)
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
if suffix in {".md", ".markdown"}:
|
|
38
|
+
return extract_markdown_diagrams(input_path, title)
|
|
39
|
+
if suffix in {".mmd", ".mermaid"}:
|
|
40
|
+
text = input_path.read_text(encoding="utf-8", errors="replace")
|
|
41
|
+
return [
|
|
42
|
+
DiagramSource(
|
|
43
|
+
id="diagram-001",
|
|
44
|
+
title=title or input_path.stem,
|
|
45
|
+
language="mermaid",
|
|
46
|
+
source_type=classify_source_type("mermaid", text),
|
|
47
|
+
text=text,
|
|
48
|
+
source_file=input_path.name,
|
|
49
|
+
origin_file=input_path.name,
|
|
50
|
+
)
|
|
51
|
+
]
|
|
52
|
+
if suffix in {".puml", ".plantuml"}:
|
|
53
|
+
text = input_path.read_text(encoding="utf-8", errors="replace")
|
|
54
|
+
return [
|
|
55
|
+
DiagramSource(
|
|
56
|
+
id="diagram-001",
|
|
57
|
+
title=title or input_path.stem,
|
|
58
|
+
language="plantuml",
|
|
59
|
+
source_type="plantuml",
|
|
60
|
+
text=text,
|
|
61
|
+
source_file=input_path.name,
|
|
62
|
+
origin_file=input_path.name,
|
|
63
|
+
warnings=["PlantUML parsing is not implemented; preview/reference output only."],
|
|
64
|
+
)
|
|
65
|
+
]
|
|
66
|
+
if suffix in {".dot", ".gv"}:
|
|
67
|
+
text = input_path.read_text(encoding="utf-8", errors="replace")
|
|
68
|
+
return [
|
|
69
|
+
DiagramSource(
|
|
70
|
+
id="diagram-001",
|
|
71
|
+
title=title or input_path.stem,
|
|
72
|
+
language="graphviz",
|
|
73
|
+
source_type="graphviz_dot",
|
|
74
|
+
text=text,
|
|
75
|
+
source_file=input_path.name,
|
|
76
|
+
origin_file=input_path.name,
|
|
77
|
+
warnings=["Graphviz parsing is not implemented; preview/reference output only."],
|
|
78
|
+
)
|
|
79
|
+
]
|
|
80
|
+
if suffix in {".svg", ".png", ".jpg", ".jpeg", ".pdf"}:
|
|
81
|
+
editability = "vector" if suffix == ".svg" else "raster"
|
|
82
|
+
return [
|
|
83
|
+
DiagramSource(
|
|
84
|
+
id="diagram-001",
|
|
85
|
+
title=title or input_path.stem,
|
|
86
|
+
language="image",
|
|
87
|
+
source_type=f"reference{suffix}",
|
|
88
|
+
text=f"Reference artifact: {input_path.name}\n",
|
|
89
|
+
source_file=input_path.name,
|
|
90
|
+
origin_file=input_path.name,
|
|
91
|
+
warnings=[f"Existing {editability} references are packaged as review artifacts, not native Visio shapes."],
|
|
92
|
+
)
|
|
93
|
+
]
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def extract_markdown_diagrams(input_path: Path, title: str = "") -> list[DiagramSource]:
|
|
98
|
+
content = input_path.read_text(encoding="utf-8", errors="replace")
|
|
99
|
+
fence_re = re.compile(
|
|
100
|
+
r"(?P<fence>```|~~~)\s*(?P<lang>[A-Za-z0-9_-]+)?[^\n]*\n(?P<body>.*?)(?:\n(?P=fence)\s*)",
|
|
101
|
+
re.DOTALL,
|
|
102
|
+
)
|
|
103
|
+
diagrams: list[DiagramSource] = []
|
|
104
|
+
for match in fence_re.finditer(content):
|
|
105
|
+
raw_lang = (match.group("lang") or "").strip().lower()
|
|
106
|
+
language = DIAGRAM_LANGS.get(raw_lang)
|
|
107
|
+
if not language:
|
|
108
|
+
continue
|
|
109
|
+
body = match.group("body").strip("\n")
|
|
110
|
+
line_offset = content[: match.start("body")].count("\n") + 1
|
|
111
|
+
diagram_title = title or nearest_heading(content[: match.start()]) or f"{input_path.stem} diagram {len(diagrams) + 1}"
|
|
112
|
+
warnings: list[str] = []
|
|
113
|
+
if language not in ("mermaid", "topology"):
|
|
114
|
+
warnings.append(f"{language} parsing is not implemented; preview/reference output only.")
|
|
115
|
+
diagrams.append(
|
|
116
|
+
DiagramSource(
|
|
117
|
+
id=f"diagram-{len(diagrams) + 1:03d}",
|
|
118
|
+
title=diagram_title,
|
|
119
|
+
language=language,
|
|
120
|
+
source_type=classify_source_type(language, body),
|
|
121
|
+
text=body + "\n",
|
|
122
|
+
line_offset=line_offset,
|
|
123
|
+
source_file=input_path.name,
|
|
124
|
+
origin_file=input_path.name,
|
|
125
|
+
warnings=warnings,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return diagrams
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def nearest_heading(markdown_prefix: str) -> str:
|
|
132
|
+
headings = re.findall(r"(?m)^#{1,6}\s+(.+?)\s*$", markdown_prefix)
|
|
133
|
+
return headings[-1].strip() if headings else ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def classify_source_type(language: str, text: str) -> str:
|
|
137
|
+
if language == "mermaid":
|
|
138
|
+
first = first_significant_line(text)
|
|
139
|
+
if re.match(r"^(flowchart|graph)\b", first, re.IGNORECASE):
|
|
140
|
+
return "mermaid_flowchart"
|
|
141
|
+
if first.lower().startswith("sequencediagram"):
|
|
142
|
+
return "mermaid_sequence"
|
|
143
|
+
return "mermaid_unknown"
|
|
144
|
+
if language == "plantuml":
|
|
145
|
+
return "plantuml"
|
|
146
|
+
if language == "graphviz":
|
|
147
|
+
return "graphviz_dot"
|
|
148
|
+
if language == "topology":
|
|
149
|
+
stripped = text.strip()
|
|
150
|
+
return "topology.json" if stripped.startswith("{") else "topology.yaml"
|
|
151
|
+
return language
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def first_significant_line(text: str) -> str:
|
|
155
|
+
for line in text.splitlines():
|
|
156
|
+
stripped = line.strip()
|
|
157
|
+
if stripped and not stripped.startswith("%%"):
|
|
158
|
+
return stripped
|
|
159
|
+
return ""
|
|
160
|
+
|