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.
@@ -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"