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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""vsdx-template engine.
|
|
2
|
+
|
|
3
|
+
Mutates a Visio template by copying stamp shapes from a template page to
|
|
4
|
+
represent each IR node, then connecting them to represent IR edges.
|
|
5
|
+
|
|
6
|
+
Activation:
|
|
7
|
+
- ``vsdx`` Python library must be importable (optional extra ``[vsdx]``).
|
|
8
|
+
- The IR must be a Mermaid flowchart (``is_flowchart_ir``).
|
|
9
|
+
- A template ``.vsdx`` file must be available, either via ``ctx.options['template']``
|
|
10
|
+
(CLI ``--template PATH``) or the bundled minimal template at
|
|
11
|
+
``src/spolvero/templates/minimal_flowchart.vsdx``.
|
|
12
|
+
|
|
13
|
+
If any precondition fails, the engine falls back to the Data Visualizer engine
|
|
14
|
+
and records a warning explaining what is needed to engage the real path.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import importlib.resources as resources
|
|
20
|
+
import importlib.util
|
|
21
|
+
import shutil
|
|
22
|
+
import traceback
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from spolvero.engines import EngineContext, EngineResult, register
|
|
27
|
+
from spolvero.engines.data_visualizer import run as data_visualizer_run
|
|
28
|
+
from spolvero.ir import is_flowchart_ir
|
|
29
|
+
from spolvero.utils import backup_if_manual_candidate
|
|
30
|
+
|
|
31
|
+
# Stamp shapes available in the bundled template.
|
|
32
|
+
_RECTANGLE_STAMP = "RECTANGLE"
|
|
33
|
+
_CIRCLE_STAMP = "CIRCLE"
|
|
34
|
+
_CONNECTOR_STAMP = "STRAIGHT_CONNECTOR"
|
|
35
|
+
_KNOWN_STAMPS = {
|
|
36
|
+
_RECTANGLE_STAMP,
|
|
37
|
+
_CIRCLE_STAMP,
|
|
38
|
+
_CONNECTOR_STAMP,
|
|
39
|
+
"CONNECTED_SHAPE",
|
|
40
|
+
"CURVED_CONNECTOR",
|
|
41
|
+
"LINE",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# IR shape_kind -> stamp name. The bundled template only carries rectangles
|
|
45
|
+
# and circles; richer stencils require a user-supplied template.
|
|
46
|
+
_KIND_TO_STAMP = {
|
|
47
|
+
"terminator": _CIRCLE_STAMP,
|
|
48
|
+
"circle": _CIRCLE_STAMP,
|
|
49
|
+
"decision": _RECTANGLE_STAMP,
|
|
50
|
+
"process": _RECTANGLE_STAMP,
|
|
51
|
+
"rounded": _RECTANGLE_STAMP,
|
|
52
|
+
"subprocess": _RECTANGLE_STAMP,
|
|
53
|
+
"document": _RECTANGLE_STAMP,
|
|
54
|
+
"data_io": _RECTANGLE_STAMP,
|
|
55
|
+
"database": _RECTANGLE_STAMP,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _vsdx_available() -> bool:
|
|
60
|
+
return importlib.util.find_spec("vsdx") is not None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _bundled_template_path() -> Path | None:
|
|
64
|
+
try:
|
|
65
|
+
ref = resources.files("spolvero.templates").joinpath("minimal_flowchart.vsdx")
|
|
66
|
+
return Path(str(ref)) if ref.is_file() else None
|
|
67
|
+
except (FileNotFoundError, ModuleNotFoundError):
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_template(ctx: EngineContext) -> Path | None:
|
|
72
|
+
override = ctx.options.get("template")
|
|
73
|
+
if override:
|
|
74
|
+
candidate = Path(override).expanduser().resolve()
|
|
75
|
+
return candidate if candidate.is_file() else None
|
|
76
|
+
return _bundled_template_path()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _fallback(ctx: EngineContext, reason: str) -> EngineResult:
|
|
80
|
+
result = data_visualizer_run(ctx)
|
|
81
|
+
if result.workbook_path is not None:
|
|
82
|
+
fallback_output = "Generated Data Visualizer workbook and preview fallbacks instead."
|
|
83
|
+
else:
|
|
84
|
+
fallback_output = "Generated preview/reference artifacts only."
|
|
85
|
+
result.warnings.insert(
|
|
86
|
+
0,
|
|
87
|
+
f"Requested target '{ctx.target}' (engine '{ctx.requested_engine}'): "
|
|
88
|
+
f"{reason} Cascading to data-visualizer/internal preview. {fallback_output}",
|
|
89
|
+
)
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _layout_inches(ir: dict[str, Any]) -> dict[str, dict[str, float]]:
|
|
94
|
+
"""Compute simple grid coordinates in inches keyed by node id."""
|
|
95
|
+
|
|
96
|
+
nodes = ir.get("nodes", [])
|
|
97
|
+
edges = ir.get("edges", [])
|
|
98
|
+
node_ids = [n["id"] for n in nodes]
|
|
99
|
+
outgoing: dict[str, list[str]] = {nid: [] for nid in node_ids}
|
|
100
|
+
incoming: dict[str, int] = {nid: 0 for nid in node_ids}
|
|
101
|
+
for edge in edges:
|
|
102
|
+
if edge["from"] in outgoing and edge["to"] in incoming:
|
|
103
|
+
outgoing[edge["from"]].append(edge["to"])
|
|
104
|
+
incoming[edge["to"]] += 1
|
|
105
|
+
|
|
106
|
+
queue = [nid for nid in node_ids if incoming[nid] == 0]
|
|
107
|
+
rank = {nid: 0 for nid in node_ids}
|
|
108
|
+
seen: set[str] = set()
|
|
109
|
+
while queue:
|
|
110
|
+
nid = queue.pop(0)
|
|
111
|
+
seen.add(nid)
|
|
112
|
+
for tgt in outgoing[nid]:
|
|
113
|
+
rank[tgt] = max(rank[tgt], rank[nid] + 1)
|
|
114
|
+
incoming[tgt] -= 1
|
|
115
|
+
if incoming[tgt] == 0:
|
|
116
|
+
queue.append(tgt)
|
|
117
|
+
if len(seen) < len(node_ids):
|
|
118
|
+
fallback_rank = max(rank.values(), default=0) + 1
|
|
119
|
+
for nid in node_ids:
|
|
120
|
+
if nid not in seen:
|
|
121
|
+
rank[nid] = fallback_rank
|
|
122
|
+
|
|
123
|
+
groups: dict[int, list[str]] = {}
|
|
124
|
+
for nid in node_ids:
|
|
125
|
+
groups.setdefault(rank[nid], []).append(nid)
|
|
126
|
+
|
|
127
|
+
orientation = ir.get("orientation", "left_to_right")
|
|
128
|
+
margin_x = 1.0
|
|
129
|
+
margin_y = 1.0
|
|
130
|
+
x_gap = 2.5
|
|
131
|
+
y_gap = 1.5
|
|
132
|
+
page_top = 10.5 # inches; pin Y is measured from page bottom
|
|
133
|
+
|
|
134
|
+
positions: dict[str, dict[str, float]] = {}
|
|
135
|
+
for rank_value in sorted(groups):
|
|
136
|
+
for index, nid in enumerate(groups[rank_value]):
|
|
137
|
+
if orientation == "left_to_right":
|
|
138
|
+
x = margin_x + rank_value * x_gap
|
|
139
|
+
y = page_top - index * y_gap - margin_y
|
|
140
|
+
else:
|
|
141
|
+
x = margin_x + index * x_gap
|
|
142
|
+
y = page_top - rank_value * y_gap - margin_y
|
|
143
|
+
positions[nid] = {"x": x, "y": max(y, 1.0)}
|
|
144
|
+
return positions
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _try_run(ctx: EngineContext) -> EngineResult:
|
|
148
|
+
template_path = _resolve_template(ctx)
|
|
149
|
+
if template_path is None:
|
|
150
|
+
return _fallback(
|
|
151
|
+
ctx,
|
|
152
|
+
"no template available (provide --template PATH or reinstall with "
|
|
153
|
+
"the [vsdx] extra so the bundled template is on disk).",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
import vsdx # local import; we already feature-detected
|
|
157
|
+
|
|
158
|
+
output_path = ctx.visio_dir / f"{ctx.diagram.id}.vsdx"
|
|
159
|
+
backup_if_manual_candidate(output_path)
|
|
160
|
+
shutil.copy2(template_path, output_path)
|
|
161
|
+
|
|
162
|
+
visio = vsdx.VisioFile(str(output_path))
|
|
163
|
+
try:
|
|
164
|
+
page = visio.pages[0]
|
|
165
|
+
stamps = {shape.text.strip(): shape for shape in page.child_shapes}
|
|
166
|
+
|
|
167
|
+
rect = stamps.get(_RECTANGLE_STAMP)
|
|
168
|
+
connector = stamps.get(_CONNECTOR_STAMP)
|
|
169
|
+
if rect is None or connector is None:
|
|
170
|
+
visio.close_vsdx()
|
|
171
|
+
return _fallback(
|
|
172
|
+
ctx,
|
|
173
|
+
f"template at '{template_path}' is missing required stamp shapes "
|
|
174
|
+
f"({_RECTANGLE_STAMP}, {_CONNECTOR_STAMP}).",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
positions = _layout_inches(ctx.ir)
|
|
178
|
+
new_shapes: dict[str, Any] = {}
|
|
179
|
+
|
|
180
|
+
for node in ctx.ir.get("nodes", []):
|
|
181
|
+
kind = node.get("shape_kind", "process")
|
|
182
|
+
stamp_name = _KIND_TO_STAMP.get(kind, _RECTANGLE_STAMP)
|
|
183
|
+
stamp = stamps.get(stamp_name) or rect
|
|
184
|
+
new_xml = visio.copy_shape(stamp.xml, page)
|
|
185
|
+
new_id = new_xml.attrib.get("ID")
|
|
186
|
+
new_shape = page.find_shape_by_id(new_id)
|
|
187
|
+
pos = positions.get(node["id"], {"x": 1.0, "y": 5.0})
|
|
188
|
+
visio.set_shape_location(new_shape.xml, pos["x"], pos["y"])
|
|
189
|
+
visio.set_shape_text(new_shape.xml, node.get("label", node["id"]))
|
|
190
|
+
new_shapes[node["id"]] = new_shape
|
|
191
|
+
|
|
192
|
+
for edge in ctx.ir.get("edges", []):
|
|
193
|
+
src = new_shapes.get(edge["from"])
|
|
194
|
+
dst = new_shapes.get(edge["to"])
|
|
195
|
+
if src is None or dst is None:
|
|
196
|
+
continue
|
|
197
|
+
new_xml = visio.copy_shape(connector.xml, page)
|
|
198
|
+
new_id = new_xml.attrib.get("ID")
|
|
199
|
+
new_shape = page.find_shape_by_id(new_id)
|
|
200
|
+
try:
|
|
201
|
+
new_shape.set_start_and_finish((src.x, src.y), (dst.x, dst.y))
|
|
202
|
+
except Exception:
|
|
203
|
+
visio.set_shape_location(
|
|
204
|
+
new_shape.xml,
|
|
205
|
+
(src.x + dst.x) / 2,
|
|
206
|
+
(src.y + dst.y) / 2,
|
|
207
|
+
)
|
|
208
|
+
label = edge.get("label", "")
|
|
209
|
+
if label:
|
|
210
|
+
visio.set_shape_text(new_shape.xml, label)
|
|
211
|
+
|
|
212
|
+
# Remove original stamp shapes so the page only carries diagram content.
|
|
213
|
+
for shape in list(page.child_shapes):
|
|
214
|
+
if shape.text.strip() in _KNOWN_STAMPS:
|
|
215
|
+
shape.remove()
|
|
216
|
+
|
|
217
|
+
visio.save_vsdx(str(output_path))
|
|
218
|
+
except Exception as exc: # pragma: no cover - defensive guard
|
|
219
|
+
visio.close_vsdx()
|
|
220
|
+
last_frame = traceback.format_exc(limit=2).strip().splitlines()[-1]
|
|
221
|
+
return _fallback(
|
|
222
|
+
ctx,
|
|
223
|
+
f"vsdx engine raised {type(exc).__name__}: {exc}. ({last_frame})",
|
|
224
|
+
)
|
|
225
|
+
visio.close_vsdx()
|
|
226
|
+
|
|
227
|
+
return EngineResult(
|
|
228
|
+
actual_engine="vsdx_template_mutation",
|
|
229
|
+
editability="semi_native",
|
|
230
|
+
output_vsdx=output_path,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def run(ctx: EngineContext) -> EngineResult:
|
|
235
|
+
if not is_flowchart_ir(ctx.ir):
|
|
236
|
+
ir_type = str(ctx.ir.get("diagram_type") or "unknown")
|
|
237
|
+
return _fallback(
|
|
238
|
+
ctx,
|
|
239
|
+
f"vsdx-template cannot handle source type '{ctx.diagram.source_type}' "
|
|
240
|
+
f"with IR type '{ir_type}'; only Mermaid flowchart IR with recognized nodes is supported.",
|
|
241
|
+
)
|
|
242
|
+
if not _vsdx_available():
|
|
243
|
+
return _fallback(
|
|
244
|
+
ctx,
|
|
245
|
+
"the 'vsdx' library is not installed (install with: "
|
|
246
|
+
"pip install 'spolvero[vsdx]').",
|
|
247
|
+
)
|
|
248
|
+
return _try_run(ctx)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
register("vsdx-template", run)
|
spolvero/hygiene.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Repository hygiene checks for migration readiness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from spolvero.inspection import inspect_vsdx, scan_text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_PERSONAL_TERMS = ("Bar" + "ry", "John" + "ston")
|
|
14
|
+
DEFAULT_ALLOWED_VSDX = ("src/spolvero/templates/minimal_flowchart.vsdx",)
|
|
15
|
+
GENERATED_PREFIXES = ("spolvero-handoff/", "spolvero/", "sample-output/", "generated/", "packages/", ".tmp/")
|
|
16
|
+
GENERATED_PREFIX_NAMES = ("spolvero-",)
|
|
17
|
+
TEXT_EXTENSIONS = {
|
|
18
|
+
".cfg",
|
|
19
|
+
".css",
|
|
20
|
+
".dot",
|
|
21
|
+
".ini",
|
|
22
|
+
".json",
|
|
23
|
+
".md",
|
|
24
|
+
".mmd",
|
|
25
|
+
".puml",
|
|
26
|
+
".py",
|
|
27
|
+
".txt",
|
|
28
|
+
".toml",
|
|
29
|
+
".yaml",
|
|
30
|
+
".yml",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_hygiene(
|
|
35
|
+
root: str | Path = ".",
|
|
36
|
+
*,
|
|
37
|
+
forbidden_terms: list[str] | None = None,
|
|
38
|
+
personal_terms: list[str] | None = None,
|
|
39
|
+
allowed_vsdx: list[str] | None = None,
|
|
40
|
+
include_untracked: bool = False,
|
|
41
|
+
tracked_files: list[str] | None = None,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
"""Run migration hygiene checks and return a structured report."""
|
|
44
|
+
|
|
45
|
+
repo_root = Path(root).resolve()
|
|
46
|
+
personal = list(dict.fromkeys([*DEFAULT_PERSONAL_TERMS, *(personal_terms or [])]))
|
|
47
|
+
forbidden = list(forbidden_terms or []) + _env_terms()
|
|
48
|
+
allowed = {_normalize_path(path) for path in (allowed_vsdx or list(DEFAULT_ALLOWED_VSDX))}
|
|
49
|
+
git_error: str | None = None
|
|
50
|
+
if tracked_files is not None:
|
|
51
|
+
tracked = tracked_files
|
|
52
|
+
else:
|
|
53
|
+
try:
|
|
54
|
+
tracked = _git_files(repo_root, include_untracked=include_untracked)
|
|
55
|
+
except RuntimeError as exc:
|
|
56
|
+
tracked = []
|
|
57
|
+
git_error = str(exc)
|
|
58
|
+
|
|
59
|
+
report: dict[str, Any] = {
|
|
60
|
+
"root": str(repo_root),
|
|
61
|
+
"checked_files": len(tracked),
|
|
62
|
+
"include_untracked": include_untracked,
|
|
63
|
+
"findings": [],
|
|
64
|
+
"vsdx_reports": [],
|
|
65
|
+
"passed": True,
|
|
66
|
+
}
|
|
67
|
+
if git_error:
|
|
68
|
+
_add_finding(report, "git_unavailable", str(repo_root), git_error)
|
|
69
|
+
|
|
70
|
+
for raw_path in sorted(tracked):
|
|
71
|
+
rel_path = _normalize_path(raw_path)
|
|
72
|
+
path = repo_root / rel_path
|
|
73
|
+
if not path.exists():
|
|
74
|
+
_add_finding(report, "missing_file", rel_path, "Listed path does not exist; scan is incomplete.")
|
|
75
|
+
continue
|
|
76
|
+
if not path.is_file():
|
|
77
|
+
_add_finding(report, "non_file", rel_path, "Listed path is not a regular file; scan is incomplete.")
|
|
78
|
+
continue
|
|
79
|
+
if _is_generated_package_path(rel_path):
|
|
80
|
+
_add_finding(report, "generated_artifact", rel_path, "Generated handoff output must stay untracked.")
|
|
81
|
+
suffix = path.suffix.lower()
|
|
82
|
+
if suffix == ".xlsx":
|
|
83
|
+
_add_finding(report, "stray_workbook", rel_path, "Tracked workbooks are generated artifacts.")
|
|
84
|
+
if suffix == ".vsdx":
|
|
85
|
+
if rel_path not in allowed:
|
|
86
|
+
_add_finding(report, "stray_vsdx", rel_path, "Tracked VSDX files need explicit approval.")
|
|
87
|
+
vsdx_report = inspect_vsdx(path, personal_terms=personal, forbidden_terms=forbidden)
|
|
88
|
+
report["vsdx_reports"].append(vsdx_report)
|
|
89
|
+
for finding in vsdx_report["findings"]:
|
|
90
|
+
_add_finding(
|
|
91
|
+
report,
|
|
92
|
+
f"vsdx_{finding['category']}",
|
|
93
|
+
f"{rel_path}:{finding['source']}",
|
|
94
|
+
finding["excerpt"],
|
|
95
|
+
line=finding["line"],
|
|
96
|
+
)
|
|
97
|
+
if _looks_textual(path):
|
|
98
|
+
try:
|
|
99
|
+
text = path.read_text(encoding="utf-8")
|
|
100
|
+
except UnicodeDecodeError:
|
|
101
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
102
|
+
for finding in scan_text(text, rel_path, personal, forbidden):
|
|
103
|
+
_add_finding(report, finding["category"], rel_path, finding["excerpt"], line=finding["line"])
|
|
104
|
+
|
|
105
|
+
report["passed"] = not report["findings"]
|
|
106
|
+
return report
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render_hygiene_report(report: dict[str, Any]) -> str:
|
|
110
|
+
"""Render a compact Markdown hygiene report."""
|
|
111
|
+
|
|
112
|
+
lines = [
|
|
113
|
+
"# Repository Hygiene Report",
|
|
114
|
+
"",
|
|
115
|
+
f"- Root: `{report['root']}`",
|
|
116
|
+
f"- Checked files: {report['checked_files']}",
|
|
117
|
+
f"- Included untracked files: {'yes' if report['include_untracked'] else 'no'}",
|
|
118
|
+
f"- VSDX packages inspected: {len(report['vsdx_reports'])}",
|
|
119
|
+
f"- Findings: {len(report['findings'])}",
|
|
120
|
+
f"- Result: {'PASS' if report['passed'] else 'FAIL'}",
|
|
121
|
+
]
|
|
122
|
+
if report["vsdx_reports"]:
|
|
123
|
+
lines.extend(["", "## VSDX Packages"])
|
|
124
|
+
for vsdx in report["vsdx_reports"]:
|
|
125
|
+
lines.append(
|
|
126
|
+
f"- `{_repo_display(report['root'], vsdx['path'])}`: {vsdx['part_count']} parts, "
|
|
127
|
+
f"{len(vsdx['media'])} media part(s), {len(vsdx['findings'])} finding(s)"
|
|
128
|
+
)
|
|
129
|
+
if report["findings"]:
|
|
130
|
+
lines.extend(["", "## Findings"])
|
|
131
|
+
for finding in report["findings"]:
|
|
132
|
+
line = f"- {finding['category']} in `{finding['path']}`"
|
|
133
|
+
if finding.get("line") is not None:
|
|
134
|
+
line += f" line {finding['line']}"
|
|
135
|
+
line += f": {finding['message']}"
|
|
136
|
+
lines.append(line)
|
|
137
|
+
return "\n".join(lines) + "\n"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _git_files(root: Path, *, include_untracked: bool) -> list[str]:
|
|
141
|
+
cmd = ["git", "-C", str(root), "ls-files", "-z", "--cached"]
|
|
142
|
+
if include_untracked:
|
|
143
|
+
cmd.extend(["--others", "--exclude-standard"])
|
|
144
|
+
result = subprocess.run(cmd, check=False, capture_output=True)
|
|
145
|
+
if result.returncode != 0:
|
|
146
|
+
detail = result.stderr.decode("utf-8", errors="replace").strip() or "git ls-files failed"
|
|
147
|
+
raise RuntimeError(f"Could not enumerate git files: {detail}")
|
|
148
|
+
return [
|
|
149
|
+
item
|
|
150
|
+
for item in result.stdout.decode("utf-8", errors="replace").split("\0")
|
|
151
|
+
if item and not item.endswith("/")
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _env_terms() -> list[str]:
|
|
156
|
+
raw = os.environ.get("SPOLVERO_FORBIDDEN_TERMS", "")
|
|
157
|
+
return [term.strip() for term in raw.split(",") if term.strip()]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _normalize_path(path: str | Path) -> str:
|
|
161
|
+
normalized = str(path).replace("\\", "/")
|
|
162
|
+
while normalized.startswith("./"):
|
|
163
|
+
normalized = normalized[2:]
|
|
164
|
+
return normalized
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _is_generated_package_path(path: str) -> bool:
|
|
168
|
+
if path.startswith(GENERATED_PREFIXES):
|
|
169
|
+
return True
|
|
170
|
+
first = path.split("/", 1)[0]
|
|
171
|
+
return first.startswith(GENERATED_PREFIX_NAMES)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _looks_textual(path: Path) -> bool:
|
|
175
|
+
if path.suffix.lower() in TEXT_EXTENSIONS:
|
|
176
|
+
return True
|
|
177
|
+
try:
|
|
178
|
+
sample = path.read_bytes()[:2048]
|
|
179
|
+
except OSError:
|
|
180
|
+
return False
|
|
181
|
+
if b"\0" in sample:
|
|
182
|
+
return False
|
|
183
|
+
try:
|
|
184
|
+
sample.decode("utf-8")
|
|
185
|
+
except UnicodeDecodeError:
|
|
186
|
+
return False
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _add_finding(
|
|
191
|
+
report: dict[str, Any],
|
|
192
|
+
category: str,
|
|
193
|
+
path: str,
|
|
194
|
+
message: str,
|
|
195
|
+
*,
|
|
196
|
+
line: int | None = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
report["findings"].append({"category": category, "path": path, "line": line, "message": message})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _repo_display(root: str, path: str) -> str:
|
|
202
|
+
try:
|
|
203
|
+
return Path(path).resolve().relative_to(Path(root).resolve()).as_posix()
|
|
204
|
+
except ValueError:
|
|
205
|
+
return path
|
spolvero/inspection.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Read-only VSDX package inspection helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from xml.etree import ElementTree as ET
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
|
13
|
+
PRIVATE_IPV4_RE = re.compile(
|
|
14
|
+
r"\b(?:10\.(?:\d{1,3}\.){2}\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|"
|
|
15
|
+
r"172\.(?:1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3})\b"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
XML_SUFFIXES = (".xml", ".rels")
|
|
19
|
+
MEDIA_SUFFIXES = (".bmp", ".emf", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff", ".wmf")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def inspect_vsdx(
|
|
23
|
+
path: str | Path,
|
|
24
|
+
*,
|
|
25
|
+
personal_terms: list[str] | None = None,
|
|
26
|
+
forbidden_terms: list[str] | None = None,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""Inspect a Visio package without modifying it."""
|
|
29
|
+
|
|
30
|
+
source = Path(path)
|
|
31
|
+
report: dict[str, Any] = {
|
|
32
|
+
"path": str(source),
|
|
33
|
+
"is_zip": False,
|
|
34
|
+
"part_count": 0,
|
|
35
|
+
"package_parts": [],
|
|
36
|
+
"doc_props": {},
|
|
37
|
+
"relationships": [],
|
|
38
|
+
"xml_parts": [],
|
|
39
|
+
"media": [],
|
|
40
|
+
"findings": [],
|
|
41
|
+
"warnings": [],
|
|
42
|
+
}
|
|
43
|
+
if not source.exists():
|
|
44
|
+
report["warnings"].append("file does not exist")
|
|
45
|
+
return report
|
|
46
|
+
if not zipfile.is_zipfile(source):
|
|
47
|
+
report["warnings"].append("file is not a valid zip package")
|
|
48
|
+
return report
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
with zipfile.ZipFile(source) as archive:
|
|
52
|
+
report["is_zip"] = True
|
|
53
|
+
entries = sorted(archive.infolist(), key=lambda item: item.filename)
|
|
54
|
+
report["part_count"] = len(entries)
|
|
55
|
+
for entry in entries:
|
|
56
|
+
name = entry.filename
|
|
57
|
+
part = {"path": name, "size": entry.file_size}
|
|
58
|
+
report["package_parts"].append(part)
|
|
59
|
+
lower_name = name.lower()
|
|
60
|
+
if _is_media_part(lower_name):
|
|
61
|
+
report["media"].append(part)
|
|
62
|
+
if lower_name.endswith(XML_SUFFIXES):
|
|
63
|
+
report["xml_parts"].append(part)
|
|
64
|
+
try:
|
|
65
|
+
text = _read_zip_text(archive, name)
|
|
66
|
+
except (OSError, RuntimeError, zipfile.BadZipFile) as exc:
|
|
67
|
+
report["warnings"].append(f"could not read package part '{name}': {exc}")
|
|
68
|
+
continue
|
|
69
|
+
report["findings"].extend(scan_text(text, name, personal_terms, forbidden_terms))
|
|
70
|
+
if lower_name.startswith("docprops/"):
|
|
71
|
+
parsed = _parse_properties(text)
|
|
72
|
+
report["doc_props"][name] = parsed if parsed else {"_raw_text": _compact_text(text)}
|
|
73
|
+
if lower_name.endswith(".rels"):
|
|
74
|
+
report["relationships"].extend(_parse_relationships(text, name))
|
|
75
|
+
except (OSError, zipfile.BadZipFile) as exc:
|
|
76
|
+
report["is_zip"] = False
|
|
77
|
+
report["warnings"].append(f"could not open zip package: {exc}")
|
|
78
|
+
|
|
79
|
+
return report
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def scan_text(
|
|
83
|
+
text: str,
|
|
84
|
+
source: str,
|
|
85
|
+
personal_terms: list[str] | None = None,
|
|
86
|
+
forbidden_terms: list[str] | None = None,
|
|
87
|
+
) -> list[dict[str, Any]]:
|
|
88
|
+
"""Return sensitive-pattern findings for a text blob."""
|
|
89
|
+
|
|
90
|
+
patterns: list[tuple[str, re.Pattern[str]]] = [
|
|
91
|
+
("email", EMAIL_RE),
|
|
92
|
+
("private_ipv4", PRIVATE_IPV4_RE),
|
|
93
|
+
]
|
|
94
|
+
for term in personal_terms or []:
|
|
95
|
+
if term:
|
|
96
|
+
patterns.append(("personal_name", re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE)))
|
|
97
|
+
for term in forbidden_terms or []:
|
|
98
|
+
if term:
|
|
99
|
+
patterns.append(("forbidden_term", re.compile(re.escape(term), re.IGNORECASE)))
|
|
100
|
+
|
|
101
|
+
findings: list[dict[str, Any]] = []
|
|
102
|
+
for line_number, line in enumerate(text.splitlines() or [text], start=1):
|
|
103
|
+
for category, pattern in patterns:
|
|
104
|
+
for match in pattern.finditer(line):
|
|
105
|
+
findings.append(
|
|
106
|
+
{
|
|
107
|
+
"category": category,
|
|
108
|
+
"source": source,
|
|
109
|
+
"line": line_number,
|
|
110
|
+
"excerpt": _redacted_excerpt(line, match.start(), match.end(), category),
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
return findings
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def render_vsdx_report(report: dict[str, Any]) -> str:
|
|
117
|
+
"""Render a compact Markdown inspection report."""
|
|
118
|
+
|
|
119
|
+
lines = [
|
|
120
|
+
f"# VSDX Inspection: {report['path']}",
|
|
121
|
+
"",
|
|
122
|
+
f"- Valid ZIP package: {_yes_no(report['is_zip'])}",
|
|
123
|
+
f"- Package parts: {report['part_count']}",
|
|
124
|
+
f"- XML/relationship parts: {len(report['xml_parts'])}",
|
|
125
|
+
f"- Media parts: {len(report['media'])}",
|
|
126
|
+
f"- Sensitive-pattern findings: {len(report['findings'])}",
|
|
127
|
+
]
|
|
128
|
+
if report["warnings"]:
|
|
129
|
+
lines.extend(["", "## Warnings"])
|
|
130
|
+
lines.extend(f"- {warning}" for warning in report["warnings"])
|
|
131
|
+
if report["doc_props"]:
|
|
132
|
+
lines.extend(["", "## Document Properties"])
|
|
133
|
+
for part, props in sorted(report["doc_props"].items()):
|
|
134
|
+
summary = ", ".join(f"{key}={value!r}" for key, value in sorted(props.items()) if value)
|
|
135
|
+
lines.append(f"- `{part}`: {summary or '(empty)'}")
|
|
136
|
+
if report["relationships"]:
|
|
137
|
+
lines.extend(["", "## Relationships"])
|
|
138
|
+
for rel in report["relationships"]:
|
|
139
|
+
lines.append(f"- `{rel['part']}` {rel['id']} -> {rel['target']} ({rel['type_name']})")
|
|
140
|
+
if report["media"]:
|
|
141
|
+
lines.extend(["", "## Media Inventory"])
|
|
142
|
+
lines.extend(f"- `{part['path']}` ({part['size']} bytes)" for part in report["media"])
|
|
143
|
+
if report["findings"]:
|
|
144
|
+
lines.extend(["", "## Findings"])
|
|
145
|
+
for finding in report["findings"]:
|
|
146
|
+
lines.append(
|
|
147
|
+
f"- {finding['category']} in `{finding['source']}` line {finding['line']}: "
|
|
148
|
+
f"{finding['excerpt']}"
|
|
149
|
+
)
|
|
150
|
+
return "\n".join(lines) + "\n"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _read_zip_text(archive: zipfile.ZipFile, name: str) -> str:
|
|
154
|
+
raw = archive.read(name)
|
|
155
|
+
for encoding in ("utf-8", "utf-16", "latin-1"):
|
|
156
|
+
try:
|
|
157
|
+
return raw.decode(encoding)
|
|
158
|
+
except UnicodeDecodeError:
|
|
159
|
+
continue
|
|
160
|
+
return raw.decode("utf-8", errors="replace")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _parse_properties(text: str) -> dict[str, str]:
|
|
164
|
+
try:
|
|
165
|
+
root = ET.fromstring(text)
|
|
166
|
+
except ET.ParseError:
|
|
167
|
+
return {}
|
|
168
|
+
props: dict[str, str] = {}
|
|
169
|
+
for element in root.iter():
|
|
170
|
+
if list(element):
|
|
171
|
+
continue
|
|
172
|
+
value = (element.text or "").strip()
|
|
173
|
+
if not value:
|
|
174
|
+
continue
|
|
175
|
+
key = element.attrib.get("name") or _local_name(element.tag)
|
|
176
|
+
props[key] = value
|
|
177
|
+
return props
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_relationships(text: str, part: str) -> list[dict[str, str]]:
|
|
181
|
+
try:
|
|
182
|
+
root = ET.fromstring(text)
|
|
183
|
+
except ET.ParseError:
|
|
184
|
+
return []
|
|
185
|
+
relationships: list[dict[str, str]] = []
|
|
186
|
+
for element in root.iter():
|
|
187
|
+
if _local_name(element.tag) != "Relationship":
|
|
188
|
+
continue
|
|
189
|
+
rel_type = element.attrib.get("Type", "")
|
|
190
|
+
relationships.append(
|
|
191
|
+
{
|
|
192
|
+
"part": part,
|
|
193
|
+
"id": element.attrib.get("Id", ""),
|
|
194
|
+
"target": element.attrib.get("Target", ""),
|
|
195
|
+
"target_mode": element.attrib.get("TargetMode", ""),
|
|
196
|
+
"type": rel_type,
|
|
197
|
+
"type_name": rel_type.rstrip("/").rsplit("/", 1)[-1],
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
return relationships
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _is_media_part(lower_name: str) -> bool:
|
|
204
|
+
return "/media/" in lower_name or lower_name.startswith("docprops/thumbnail") or lower_name.endswith(MEDIA_SUFFIXES)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _local_name(tag: str) -> str:
|
|
208
|
+
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _compact_text(text: str) -> str:
|
|
212
|
+
return " ".join(text.split())[:200]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _redacted_excerpt(line: str, start: int, end: int, category: str) -> str:
|
|
216
|
+
redacted = f"[{category}]"
|
|
217
|
+
excerpt = line[:start] + redacted + line[end:]
|
|
218
|
+
return excerpt.strip()[:180]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _yes_no(value: bool) -> str:
|
|
222
|
+
return "yes" if value else "no"
|