docx-cli 0.1.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.
- docx_cli/__init__.py +8 -0
- docx_cli/__main__.py +7 -0
- docx_cli/_templates/hodatajstvo.json +60 -0
- docx_cli/_templates/iskovoe_zayavlenie.json +75 -0
- docx_cli/_templates/otzyv_na_isk.json +69 -0
- docx_cli/_templates/soyu_rayon.json +61 -0
- docx_cli/_templates/vs_sud.json +65 -0
- docx_cli/agent_reader.py +87 -0
- docx_cli/blocks.py +333 -0
- docx_cli/builder.py +116 -0
- docx_cli/cli.py +641 -0
- docx_cli/errors.py +21 -0
- docx_cli/fonts_normalize.py +119 -0
- docx_cli/inject_scan.py +369 -0
- docx_cli/mcp_server.py +380 -0
- docx_cli/md_court.py +665 -0
- docx_cli/profiles.py +272 -0
- docx_cli/spec.py +233 -0
- docx_cli/validate.py +786 -0
- docx_cli-0.1.0.dist-info/METADATA +95 -0
- docx_cli-0.1.0.dist-info/RECORD +25 -0
- docx_cli-0.1.0.dist-info/WHEEL +4 -0
- docx_cli-0.1.0.dist-info/entry_points.txt +3 -0
- docx_cli-0.1.0.dist-info/licenses/LICENSE +199 -0
- docx_cli-0.1.0.dist-info/licenses/NOTICE +1 -0
docx_cli/validate.py
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
"""Built-in post-build validation: reopen the generated .docx and verify it."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import zipfile
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from docx import Document
|
|
9
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
10
|
+
from docx.oxml import parse_xml
|
|
11
|
+
from docx.oxml.ns import qn
|
|
12
|
+
|
|
13
|
+
from docx_cli.spec import DocSpec, Profile, now_iso
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
|
17
|
+
CONTENT_TYPES = "[Content_Types].xml"
|
|
18
|
+
RELS = "_rels/.rels"
|
|
19
|
+
DOC_RELS = "word/_rels/document.xml.rels"
|
|
20
|
+
DOCUMENT_XML = "word/document.xml"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_REQUISITE_RE = re.compile(r"(паспорт|снилс|инн|кпп|огрн)", re.IGNORECASE)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cm(value: Any) -> float:
|
|
27
|
+
return float(value.cm) if value is not None else 0.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _pt(value: Any) -> float | None:
|
|
31
|
+
return float(value.pt) if value is not None else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_package_integrity(path: str) -> list[str]:
|
|
35
|
+
errors: list[str] = []
|
|
36
|
+
required = {CONTENT_TYPES, RELS, DOC_RELS, DOCUMENT_XML}
|
|
37
|
+
try:
|
|
38
|
+
with zipfile.ZipFile(path, "r") as zf:
|
|
39
|
+
names = set(zf.namelist())
|
|
40
|
+
missing = required - names
|
|
41
|
+
if missing:
|
|
42
|
+
errors.append(f"missing_required_parts: {sorted(missing)}")
|
|
43
|
+
return errors
|
|
44
|
+
for rel_file in (RELS, DOC_RELS):
|
|
45
|
+
data = zf.read(rel_file)
|
|
46
|
+
tree = parse_xml(data)
|
|
47
|
+
for rel in tree.findall(f"{{{RELS_NS}}}Relationship"):
|
|
48
|
+
target = rel.get("Target")
|
|
49
|
+
if target is None:
|
|
50
|
+
continue
|
|
51
|
+
if target.startswith("/"):
|
|
52
|
+
target = target[1:]
|
|
53
|
+
else:
|
|
54
|
+
base = "word/" if rel_file == DOC_RELS else ""
|
|
55
|
+
target = base + target
|
|
56
|
+
target = target.replace("\\", "/")
|
|
57
|
+
# Normalize ../ paths
|
|
58
|
+
parts = target.split("/")
|
|
59
|
+
stack: list[str] = []
|
|
60
|
+
for part in parts:
|
|
61
|
+
if part == "..":
|
|
62
|
+
if stack:
|
|
63
|
+
stack.pop()
|
|
64
|
+
elif part and part != ".":
|
|
65
|
+
stack.append(part)
|
|
66
|
+
normalized = "/".join(stack)
|
|
67
|
+
if normalized and normalized not in names:
|
|
68
|
+
errors.append(f"unresolved_relationship: {rel_file} -> {normalized}")
|
|
69
|
+
except zipfile.BadZipFile as exc:
|
|
70
|
+
errors.append(f"bad_zip: {exc}")
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
errors.append(f"package_integrity: {exc}")
|
|
73
|
+
return errors
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _check_margins(doc: Document, profile: Profile) -> list[str]:
|
|
77
|
+
errors: list[str] = []
|
|
78
|
+
section = doc.sections[0]
|
|
79
|
+
pairs = [
|
|
80
|
+
("left", _cm(section.left_margin), profile.margin_left_cm),
|
|
81
|
+
("right", _cm(section.right_margin), profile.margin_right_cm),
|
|
82
|
+
("top", _cm(section.top_margin), profile.margin_top_cm),
|
|
83
|
+
("bottom", _cm(section.bottom_margin), profile.margin_bottom_cm),
|
|
84
|
+
]
|
|
85
|
+
for name, actual, expected in pairs:
|
|
86
|
+
if abs(actual - expected) > 0.05:
|
|
87
|
+
errors.append(f"margin_{name}: expected {expected}cm, got {actual:.3f}cm")
|
|
88
|
+
return errors
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _iter_all_paragraphs(doc: Document):
|
|
92
|
+
"""Yield every paragraph in the document, including table cells."""
|
|
93
|
+
idx = 0
|
|
94
|
+
for p in doc.paragraphs:
|
|
95
|
+
yield False, idx, p
|
|
96
|
+
idx += 1
|
|
97
|
+
for table in doc.tables:
|
|
98
|
+
for row in table.rows:
|
|
99
|
+
for cell in row.cells:
|
|
100
|
+
for p in cell.paragraphs:
|
|
101
|
+
yield True, idx, p
|
|
102
|
+
idx += 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _expected_paragraph_texts(spec: DocSpec, profile: Profile) -> dict[str, set[str]]:
|
|
106
|
+
"""Build exact-text sets for paragraphs rendered outside normal body flow."""
|
|
107
|
+
header_texts = {line.strip() for line in spec.header.lines if line != ""}
|
|
108
|
+
title_texts = {spec.title.main.strip()}
|
|
109
|
+
if spec.title.subtitle:
|
|
110
|
+
title_texts.add(spec.title.subtitle.strip())
|
|
111
|
+
|
|
112
|
+
pleading_texts: set[str] = set()
|
|
113
|
+
signature_texts: set[str] = set()
|
|
114
|
+
list_texts: set[str] = set()
|
|
115
|
+
|
|
116
|
+
for block in spec.body:
|
|
117
|
+
if block.type == "pleading_marker":
|
|
118
|
+
pleading_texts.add(block.text.strip())
|
|
119
|
+
elif block.type == "signature_block":
|
|
120
|
+
fmt = block.format if block.format is not None else profile.default_signature_format
|
|
121
|
+
if fmt == "text":
|
|
122
|
+
signature_texts.add(f"{block.left}\t{block.right}".strip())
|
|
123
|
+
elif block.type == "numbered_list":
|
|
124
|
+
for idx, item in enumerate(block.items, start=1):
|
|
125
|
+
list_texts.add(f"{idx}. {item}".strip())
|
|
126
|
+
elif block.type == "attachments_list":
|
|
127
|
+
for item in block.items:
|
|
128
|
+
list_texts.add(item.strip())
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"header": header_texts,
|
|
132
|
+
"title": title_texts,
|
|
133
|
+
"pleading": pleading_texts,
|
|
134
|
+
"signature": signature_texts,
|
|
135
|
+
"list": list_texts,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_SIG_HEAD_RE = re.compile(
|
|
140
|
+
r"^(Защитник|Адвокат|Представитель|Ордер|Истец|Ответчик|Заявитель|"
|
|
141
|
+
r"Жалобщик|Подозреваемый|Обвиняемый)\b"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _is_service_signature(p, text: str) -> bool:
|
|
146
|
+
"""v0.5.1 (по внешней рецензии): подписная/служебная строка, набранная
|
|
147
|
+
пробелами без tab+'___'. Признаки: ролевое слово в начале строки И
|
|
148
|
+
отсутствие красной строки — обычный body-абзац («Защитник указал, что…»)
|
|
149
|
+
идёт с отступом 1.25 см и так проверяется дальше."""
|
|
150
|
+
if not _SIG_HEAD_RE.match(text):
|
|
151
|
+
return False
|
|
152
|
+
# Настоящая подпись с tab+___ проверяется отдельной веткой; это —
|
|
153
|
+
# «пробельная» подпись без таба, для неё красная строка не требуется.
|
|
154
|
+
if "\t" in text and "___" in text:
|
|
155
|
+
return False
|
|
156
|
+
fl = p.paragraph_format.first_line_indent
|
|
157
|
+
return fl is None or int(fl) == 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _classify_paragraphs(doc: Document, spec: DocSpec | None, profile: Profile | None = None,
|
|
161
|
+
skipped: list | None = None) -> dict[int, str]:
|
|
162
|
+
"""Map paragraph index to role: header, title, body, list, pleading, signature, other."""
|
|
163
|
+
roles: dict[int, str] = {}
|
|
164
|
+
if spec is not None:
|
|
165
|
+
profile = profile or Profile(spec.profile)
|
|
166
|
+
expected = _expected_paragraph_texts(spec, profile)
|
|
167
|
+
attachments_seen_spec = False # v0.5.1: зеркало fallback-логики
|
|
168
|
+
for idx, p in enumerate(doc.paragraphs):
|
|
169
|
+
text = p.text.strip()
|
|
170
|
+
if not text:
|
|
171
|
+
attachments_seen_spec = False
|
|
172
|
+
continue
|
|
173
|
+
if text in expected["header"]:
|
|
174
|
+
roles[idx] = "header"
|
|
175
|
+
elif text in expected["title"]:
|
|
176
|
+
roles[idx] = "title"
|
|
177
|
+
elif text in expected["pleading"]:
|
|
178
|
+
roles[idx] = "pleading"
|
|
179
|
+
elif text in expected["signature"]:
|
|
180
|
+
roles[idx] = "signature"
|
|
181
|
+
elif text in expected["list"]:
|
|
182
|
+
roles[idx] = "list"
|
|
183
|
+
elif re.match(r"^Приложения\s*[:.]?\s*$", text):
|
|
184
|
+
attachments_seen_spec = True
|
|
185
|
+
roles[idx] = "other"
|
|
186
|
+
elif attachments_seen_spec or _is_service_signature(p, text):
|
|
187
|
+
# v0.5.1 (по внешней рецензии): симметрично
|
|
188
|
+
# fallback-ветке — подписные/служебные строки и продолжения
|
|
189
|
+
# списка приложений, не совпавшие с exact-текстом spec,
|
|
190
|
+
# не проверяются как body (красная строка).
|
|
191
|
+
if skipped is not None:
|
|
192
|
+
if _is_service_signature(p, text):
|
|
193
|
+
skipped.append({"type": "check_skipped", "zone": idx,
|
|
194
|
+
"reason": "service_signature"})
|
|
195
|
+
elif attachments_seen_spec:
|
|
196
|
+
skipped.append({"type": "check_skipped", "zone": idx,
|
|
197
|
+
"reason": "appendices_fallback"})
|
|
198
|
+
roles[idx] = "signature"
|
|
199
|
+
else:
|
|
200
|
+
roles[idx] = "body"
|
|
201
|
+
return roles
|
|
202
|
+
|
|
203
|
+
# Fallback heuristic when no spec is supplied (standalone validate command).
|
|
204
|
+
title_idx: int | None = None
|
|
205
|
+
for idx, p in enumerate(doc.paragraphs):
|
|
206
|
+
if p.text.strip() and p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER:
|
|
207
|
+
# First centered non-empty paragraph is treated as title
|
|
208
|
+
title_idx = idx
|
|
209
|
+
break
|
|
210
|
+
# Title block = title paragraph + any immediately following centered
|
|
211
|
+
# paragraphs (subtitle chain), e.g. "ОТЗЫВ ИСТЦА" + "на возражения...".
|
|
212
|
+
title_end = title_idx
|
|
213
|
+
if title_idx is not None:
|
|
214
|
+
for idx in range(title_idx + 1, len(doc.paragraphs)):
|
|
215
|
+
p = doc.paragraphs[idx]
|
|
216
|
+
if (
|
|
217
|
+
p.text.strip()
|
|
218
|
+
and p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER
|
|
219
|
+
):
|
|
220
|
+
title_end = idx
|
|
221
|
+
else:
|
|
222
|
+
break
|
|
223
|
+
attachments_seen = False # v0.5.1: всё после «Приложения:» — служебный блок
|
|
224
|
+
for idx, p in enumerate(doc.paragraphs):
|
|
225
|
+
text = p.text.strip()
|
|
226
|
+
if not text:
|
|
227
|
+
attachments_seen = False
|
|
228
|
+
continue
|
|
229
|
+
if title_idx is not None and title_idx <= idx <= title_end:
|
|
230
|
+
roles[idx] = "title"
|
|
231
|
+
elif title_idx is not None and idx < title_idx:
|
|
232
|
+
roles[idx] = "header"
|
|
233
|
+
elif (
|
|
234
|
+
p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER
|
|
235
|
+
and (
|
|
236
|
+
p.paragraph_format.first_line_indent is None
|
|
237
|
+
or int(p.paragraph_format.first_line_indent) == 0
|
|
238
|
+
)
|
|
239
|
+
and text.upper().startswith("ПРОШУ")
|
|
240
|
+
):
|
|
241
|
+
roles[idx] = "pleading"
|
|
242
|
+
elif (
|
|
243
|
+
p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER
|
|
244
|
+
and (
|
|
245
|
+
p.paragraph_format.first_line_indent is None
|
|
246
|
+
or int(p.paragraph_format.first_line_indent) == 0
|
|
247
|
+
)
|
|
248
|
+
):
|
|
249
|
+
# v0.5.3 (фикс 1/2 инвариант): центрированные структурные блоки
|
|
250
|
+
# (подзаголовок «## …», маркеры) — не body: иначе md-build порождает
|
|
251
|
+
# документ, который бракует собственный validate по alignment.
|
|
252
|
+
roles[idx] = "other"
|
|
253
|
+
elif (
|
|
254
|
+
p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.LEFT
|
|
255
|
+
and "\t" in p.text
|
|
256
|
+
and "___" in p.text
|
|
257
|
+
):
|
|
258
|
+
roles[idx] = "signature"
|
|
259
|
+
elif attachments_seen:
|
|
260
|
+
# v0.5.1 (по внешней рецензии): продолжения
|
|
261
|
+
# списка приложений («Ордер адвоката …») и подпись после «Приложения:»
|
|
262
|
+
# — служебный блок, красная строка к ним не применяется.
|
|
263
|
+
if skipped is not None:
|
|
264
|
+
skipped.append({"type": "check_skipped", "zone": idx,
|
|
265
|
+
"reason": "appendices_fallback"})
|
|
266
|
+
roles[idx] = "other"
|
|
267
|
+
elif _is_service_signature(p, text):
|
|
268
|
+
if skipped is not None:
|
|
269
|
+
skipped.append({"type": "check_skipped", "zone": idx,
|
|
270
|
+
"reason": "service_signature"})
|
|
271
|
+
roles[idx] = "signature"
|
|
272
|
+
elif text == "Приложения:" or re.match(r"^Приложения\s*[:.]?\s*$", text) \
|
|
273
|
+
or re.match(r"^\d+\.\s", text):
|
|
274
|
+
# Numbered-list / attachment-list paragraphs are not GOST body;
|
|
275
|
+
# skip body checks rather than false-fail. In standalone mode we
|
|
276
|
+
# do not have the spec, so any "N. " paragraph is treated as a
|
|
277
|
+
# list item.
|
|
278
|
+
if text.startswith("Приложения"):
|
|
279
|
+
attachments_seen = True
|
|
280
|
+
roles[idx] = "other"
|
|
281
|
+
elif p.style.name.startswith("List") or (
|
|
282
|
+
p.paragraph_format.left_indent is not None
|
|
283
|
+
and int(p.paragraph_format.left_indent) > 0
|
|
284
|
+
):
|
|
285
|
+
# v0.1.0 B-10 (контроль координатора): style-mode вложенные
|
|
286
|
+
# списки (List Bullet/Number N) и legacy-рендер уровня 1
|
|
287
|
+
# (Normal + левый отступ 35.5pt) — структурные list-параграфы,
|
|
288
|
+
# не body: ГОСТ-красная строка/JUSTIFY к ним не применяется
|
|
289
|
+
# (ровно как уже исключаются "N. " и hanging-indent списки).
|
|
290
|
+
if skipped is not None:
|
|
291
|
+
skipped.append({"type": "check_skipped", "zone": idx,
|
|
292
|
+
"reason": "list_paragraph"})
|
|
293
|
+
roles[idx] = "other"
|
|
294
|
+
elif (
|
|
295
|
+
abs(_cm(p.paragraph_format.left_indent) - 1.25) <= 0.05
|
|
296
|
+
and abs(_cm(p.paragraph_format.first_line_indent) + 0.75) <= 0.05
|
|
297
|
+
):
|
|
298
|
+
# v0.2.0 fix: hanging-indent paragraphs (lists/attachments rendered
|
|
299
|
+
# by builder with left=1.25cm, first=-0.75cm) are structural list
|
|
300
|
+
# items even when text heuristics miss them (e.g. text-form
|
|
301
|
+
# signature lines, non-numeric list items). Точный маркер
|
|
302
|
+
# builder'а надёжнее текстовых эвристик.
|
|
303
|
+
roles[idx] = "other"
|
|
304
|
+
else:
|
|
305
|
+
roles[idx] = "body"
|
|
306
|
+
return roles
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _all_runs(p):
|
|
310
|
+
from docx.oxml.ns import qn
|
|
311
|
+
from docx.text.run import Run
|
|
312
|
+
for r in p._p.iter(qn("w:r")):
|
|
313
|
+
yield Run(r, p)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _check_fonts_and_sizes(doc: Document, spec: DocSpec | None, guessed_profile: str = "gost") -> list[str]:
|
|
317
|
+
errors: list[str] = []
|
|
318
|
+
profile = Profile(spec.profile if spec else guessed_profile, line_spacing=spec.line_spacing if spec else None)
|
|
319
|
+
roles = _classify_paragraphs(doc, spec, profile)
|
|
320
|
+
|
|
321
|
+
for is_cell, p_idx, p in _iter_all_paragraphs(doc):
|
|
322
|
+
role = roles.get(p_idx, "cell" if is_cell else "other")
|
|
323
|
+
for run in _all_runs(p):
|
|
324
|
+
text = run.text
|
|
325
|
+
if not text:
|
|
326
|
+
continue
|
|
327
|
+
name = run.font.name
|
|
328
|
+
if name != profile.font_name:
|
|
329
|
+
errors.append(f"font_name: paragraph {p_idx} run uses '{name}'")
|
|
330
|
+
size = _pt(run.font.size)
|
|
331
|
+
if role == "title":
|
|
332
|
+
is_title_main = run.bold and size == profile.title_font_size_pt
|
|
333
|
+
if not is_title_main and size != profile.body_font_size_pt:
|
|
334
|
+
errors.append(f"title_font_size: paragraph {p_idx} run size {size}pt")
|
|
335
|
+
elif role == "cell":
|
|
336
|
+
if size is not None and size != profile.cell_font_size_pt:
|
|
337
|
+
errors.append(f"cell_font_size: paragraph {p_idx} run size {size}pt")
|
|
338
|
+
else:
|
|
339
|
+
if size is not None and size != profile.body_font_size_pt:
|
|
340
|
+
errors.append(f"body_font_size: paragraph {p_idx} run size {size}pt")
|
|
341
|
+
return errors
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _check_body_formatting(doc: Document, spec: DocSpec | None, guessed_profile: str = "gost") -> list[str]:
|
|
345
|
+
errors: list[str] = []
|
|
346
|
+
profile = Profile(spec.profile if spec else guessed_profile, line_spacing=spec.line_spacing if spec else None)
|
|
347
|
+
roles = _classify_paragraphs(doc, spec, profile)
|
|
348
|
+
|
|
349
|
+
for p_idx, p in enumerate(doc.paragraphs):
|
|
350
|
+
role = roles.get(p_idx, "other")
|
|
351
|
+
text = p.text.strip()
|
|
352
|
+
is_svc = _is_service_signature(p, text)
|
|
353
|
+
if role == "body":
|
|
354
|
+
pass
|
|
355
|
+
elif role == "signature" and is_svc:
|
|
356
|
+
pass
|
|
357
|
+
else:
|
|
358
|
+
continue
|
|
359
|
+
pf = p.paragraph_format
|
|
360
|
+
align = pf.alignment
|
|
361
|
+
if align != WD_ALIGN_PARAGRAPH.JUSTIFY:
|
|
362
|
+
errors.append(f"alignment: paragraph {p_idx} expected JUSTIFY, got {align}")
|
|
363
|
+
ls = pf.line_spacing
|
|
364
|
+
if isinstance(ls, (int, float)) and abs(float(ls) - profile.body_line_spacing) > 0.05:
|
|
365
|
+
errors.append(
|
|
366
|
+
f"line_spacing: paragraph {p_idx} expected {profile.body_line_spacing}, got {ls}"
|
|
367
|
+
)
|
|
368
|
+
if role == "body":
|
|
369
|
+
indent = _cm(pf.first_line_indent)
|
|
370
|
+
if abs(indent - profile.first_line_indent_cm) > 0.05:
|
|
371
|
+
errors.append(
|
|
372
|
+
f"first_line_indent: paragraph {p_idx} expected {profile.first_line_indent_cm}cm, got {indent:.3f}cm"
|
|
373
|
+
)
|
|
374
|
+
return errors
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _check_list_formatting(doc: Document, spec: DocSpec | None, profile: Profile) -> list[str]:
|
|
378
|
+
"""Hanging indent for numbered_list and attachments_list items."""
|
|
379
|
+
errors: list[str] = []
|
|
380
|
+
if spec is None:
|
|
381
|
+
return errors
|
|
382
|
+
|
|
383
|
+
roles = _classify_paragraphs(doc, spec, profile)
|
|
384
|
+
for p_idx, p in enumerate(doc.paragraphs):
|
|
385
|
+
if roles.get(p_idx) != "list":
|
|
386
|
+
continue
|
|
387
|
+
pf = p.paragraph_format
|
|
388
|
+
left = _cm(pf.left_indent)
|
|
389
|
+
first = _cm(pf.first_line_indent)
|
|
390
|
+
if abs(left - 1.25) > 0.05:
|
|
391
|
+
errors.append(f"list_left_indent: paragraph {p_idx} expected 1.25cm, got {left:.3f}cm")
|
|
392
|
+
if abs(first - (-0.75)) > 0.05:
|
|
393
|
+
errors.append(f"list_first_line_indent: paragraph {p_idx} expected -0.75cm, got {first:.3f}cm")
|
|
394
|
+
return errors
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _check_header_formatting(doc: Document, spec: DocSpec | None, profile: Profile) -> list[str]:
|
|
398
|
+
errors: list[str] = []
|
|
399
|
+
if spec is None or profile.name != "court":
|
|
400
|
+
return errors
|
|
401
|
+
|
|
402
|
+
roles = _classify_paragraphs(doc, spec, profile)
|
|
403
|
+
expected_left = spec.header.left_indent_cm
|
|
404
|
+
for p_idx, p in enumerate(doc.paragraphs):
|
|
405
|
+
if roles.get(p_idx) != "header":
|
|
406
|
+
continue
|
|
407
|
+
pf = p.paragraph_format
|
|
408
|
+
if pf.alignment != WD_ALIGN_PARAGRAPH.LEFT:
|
|
409
|
+
errors.append(f"header_alignment: paragraph {p_idx} expected LEFT, got {pf.alignment}")
|
|
410
|
+
left = _cm(pf.left_indent)
|
|
411
|
+
if abs(left - expected_left) > 0.05:
|
|
412
|
+
errors.append(f"header_left_indent: paragraph {p_idx} expected {expected_left}cm, got {left:.3f}cm")
|
|
413
|
+
ls = pf.line_spacing
|
|
414
|
+
if isinstance(ls, (int, float)) and abs(float(ls) - profile.header_line_spacing) > 0.05:
|
|
415
|
+
errors.append(f"header_line_spacing: paragraph {p_idx} expected {profile.header_line_spacing}, got {ls}")
|
|
416
|
+
return errors
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _check_pleading_marker(doc: Document, spec: DocSpec | None, profile: Profile) -> tuple[list[str], list[str]]:
|
|
420
|
+
errors: list[str] = []
|
|
421
|
+
warnings: list[str] = []
|
|
422
|
+
if spec is None:
|
|
423
|
+
return errors, warnings
|
|
424
|
+
|
|
425
|
+
# Warn when a plain body paragraph starts with ПРОШУ in court profile.
|
|
426
|
+
if profile.name == "court":
|
|
427
|
+
for block in spec.body:
|
|
428
|
+
if block.type == "paragraph" and block.text.strip().upper().startswith("ПРОШУ"):
|
|
429
|
+
warnings.append("pleading_marker: use dedicated 'pleading_marker' block for text starting with 'ПРОШУ'")
|
|
430
|
+
break
|
|
431
|
+
|
|
432
|
+
roles = _classify_paragraphs(doc, spec, profile)
|
|
433
|
+
for p_idx, p in enumerate(doc.paragraphs):
|
|
434
|
+
if roles.get(p_idx) != "pleading":
|
|
435
|
+
continue
|
|
436
|
+
pf = p.paragraph_format
|
|
437
|
+
if pf.alignment != WD_ALIGN_PARAGRAPH.CENTER:
|
|
438
|
+
errors.append(f"pleading_alignment: paragraph {p_idx} expected CENTER, got {pf.alignment}")
|
|
439
|
+
first = _cm(pf.first_line_indent)
|
|
440
|
+
if abs(first - 0.0) > 0.05:
|
|
441
|
+
errors.append(f"pleading_first_line_indent: paragraph {p_idx} expected 0cm, got {first:.3f}cm")
|
|
442
|
+
for run in p.runs:
|
|
443
|
+
if not run.text:
|
|
444
|
+
continue
|
|
445
|
+
if not run.bold:
|
|
446
|
+
errors.append(f"pleading_bold: paragraph {p_idx} run is not bold")
|
|
447
|
+
size = _pt(run.font.size)
|
|
448
|
+
if size is not None and size != profile.body_font_size_pt:
|
|
449
|
+
errors.append(f"pleading_font_size: paragraph {p_idx} run size {size}pt")
|
|
450
|
+
return errors, warnings
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _check_en_dash(doc: Document, spec: DocSpec | None) -> tuple[list[str], list[str]]:
|
|
454
|
+
errors: list[str] = []
|
|
455
|
+
warnings: list[str] = []
|
|
456
|
+
en_dash = "\u2013"
|
|
457
|
+
no_en_dash = spec.checks.no_en_dash if spec else True
|
|
458
|
+
for is_cell, p_idx, p in _iter_all_paragraphs(doc):
|
|
459
|
+
if en_dash in p.text:
|
|
460
|
+
msg = f"en_dash: paragraph {p_idx} contains '–'"
|
|
461
|
+
if no_en_dash:
|
|
462
|
+
errors.append(msg)
|
|
463
|
+
else:
|
|
464
|
+
warnings.append(msg)
|
|
465
|
+
return errors, warnings
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _is_requisite_line(text: str) -> bool:
|
|
469
|
+
return bool(re.search(r"\d{6,}", text) or _REQUISITE_RE.search(text))
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _check_yo(doc: Document, spec: DocSpec | None) -> list[str]:
|
|
473
|
+
warnings: list[str] = []
|
|
474
|
+
if spec is not None and not spec.checks.no_yo:
|
|
475
|
+
return warnings
|
|
476
|
+
whitelist: set[str] = set()
|
|
477
|
+
if spec is not None:
|
|
478
|
+
whitelist = {line.strip() for line in spec.header.lines if _is_requisite_line(line)}
|
|
479
|
+
for is_cell, p_idx, p in _iter_all_paragraphs(doc):
|
|
480
|
+
text = p.text
|
|
481
|
+
if any(text.strip() == wl for wl in whitelist):
|
|
482
|
+
continue
|
|
483
|
+
if "ё" in text or "Ё" in text:
|
|
484
|
+
warnings.append(f"yo_present: paragraph {p_idx} contains 'ё' outside requisites")
|
|
485
|
+
return warnings
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _check_placeholders(doc: Document, spec: DocSpec | None) -> list[str]:
|
|
489
|
+
warnings: list[str] = []
|
|
490
|
+
pattern = re.compile(r"\[УКАЗАТЬ[^\]]*\]")
|
|
491
|
+
for is_cell, p_idx, p in _iter_all_paragraphs(doc):
|
|
492
|
+
if pattern.search(p.text):
|
|
493
|
+
warnings.append(f"placeholder_present: paragraph {p_idx}")
|
|
494
|
+
return warnings
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _check_norm_refs(doc: Document, spec: DocSpec | None) -> list[str]:
|
|
498
|
+
warnings: list[str] = []
|
|
499
|
+
if spec is None or not spec.checks.require_norms_refs:
|
|
500
|
+
return warnings
|
|
501
|
+
full_text = "\n".join(p.text for p in doc.paragraphs)
|
|
502
|
+
if not re.search(
|
|
503
|
+
r"(?:ст\.?\s*\d|Стать[ья]|ГК\s*РФ|КоАП|НК\s*РФ|ТК\s*РФ|ГПК\s*РФ|КАС\s*РФ)",
|
|
504
|
+
full_text,
|
|
505
|
+
):
|
|
506
|
+
warnings.append("norm_refs_missing: no legal norm references detected")
|
|
507
|
+
return warnings
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _check_numbered_list_sequence(doc: Document, spec: DocSpec | None) -> list[str]:
|
|
511
|
+
errors: list[str] = []
|
|
512
|
+
pattern = re.compile(r"^(\d+)\.\s")
|
|
513
|
+
# v0.5.1 (round-trip приёмка): блок приложений («Приложения:») — отдельный
|
|
514
|
+
# нумерованный список, его нумерация легитимно начинается с 1 после списка
|
|
515
|
+
# просительной части; в v0.5.0 законные [1 | 1] давали «out of order».
|
|
516
|
+
# v0.5.3 (фикс 3): границы сегментов — «ПРОШУ:» и
|
|
517
|
+
# «Приложения:» без учёта регистра и двоеточия/точки (рабочие документы
|
|
518
|
+
# пишут «прошу:», «приложения.», «ПРОШУ СУД:» и т.п.).
|
|
519
|
+
boundary = re.compile(r"^(?:приложения|прошу|дополнительно)(?:\b|\s*[:.])", re.IGNORECASE)
|
|
520
|
+
segments: list[list[int]] = [[]]
|
|
521
|
+
for p in doc.paragraphs:
|
|
522
|
+
t = p.text.strip()
|
|
523
|
+
if boundary.match(t):
|
|
524
|
+
if segments[-1]:
|
|
525
|
+
segments.append([])
|
|
526
|
+
continue
|
|
527
|
+
m = pattern.match(t)
|
|
528
|
+
if m:
|
|
529
|
+
segments[-1].append(int(m.group(1)))
|
|
530
|
+
for numbers in segments:
|
|
531
|
+
if not numbers:
|
|
532
|
+
continue
|
|
533
|
+
groups: list[list[int]] = []
|
|
534
|
+
current: list[int] = []
|
|
535
|
+
for n in numbers:
|
|
536
|
+
# A new list starts at 1 ONLY when the previous group is complete
|
|
537
|
+
# ([1..k]); otherwise a repeated/out-of-order number stays in the
|
|
538
|
+
# current group so the sequence check flags it (fail-closed).
|
|
539
|
+
# v0.5.3 фикс 3: полная группа может иметь ЛЮБУЮ длину, включая
|
|
540
|
+
# [1] — независимые одноэлементные перечни законны (склейка
|
|
541
|
+
# [1|1] в v0.5.2 браковала реальные документы).
|
|
542
|
+
if n == 1 and current:
|
|
543
|
+
groups.append(current)
|
|
544
|
+
current = [n]
|
|
545
|
+
else:
|
|
546
|
+
current.append(n)
|
|
547
|
+
if current:
|
|
548
|
+
groups.append(current)
|
|
549
|
+
|
|
550
|
+
for grp in groups:
|
|
551
|
+
expected = list(range(1, len(grp) + 1))
|
|
552
|
+
if grp != expected:
|
|
553
|
+
errors.append(f"numbered_list_sequence: {grp} out of order")
|
|
554
|
+
return errors
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _guess_profile(doc) -> str:
|
|
558
|
+
"""Standalone validate has no spec: guess profile from dominant font size.
|
|
559
|
+
14pt body => court profile; otherwise gost (12pt)."""
|
|
560
|
+
from collections import Counter
|
|
561
|
+
sizes = Counter()
|
|
562
|
+
for p in doc.paragraphs:
|
|
563
|
+
for run in p.runs:
|
|
564
|
+
if run.text.strip() and run.font.size is not None:
|
|
565
|
+
sizes[round(run.font.size.pt)] += 1
|
|
566
|
+
return "court" if sizes and sizes.most_common(1)[0][0] == 14 else "gost"
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# v0.5.3 (фикс 5; согласовано с внешним рецензентом): полосы геометрии для входящих
|
|
570
|
+
# процессуальных актов судов и оппонентов. В --mode review они дают ТОЛЬКО
|
|
571
|
+
# warnings; errors в review — строго контентные дефекты (плейсхолдеры,
|
|
572
|
+
# en dash, norm_refs, склейка нумерации и т.п.).
|
|
573
|
+
REVIEW_MARGIN_BAND_CM = (1.0, 3.0)
|
|
574
|
+
REVIEW_FONT_BAND_PT = (10.0, 14.0)
|
|
575
|
+
REVIEW_LINE_SPACING_BAND = (1.0, 1.5)
|
|
576
|
+
REVIEW_FIRST_LINE_BAND_CM = (0.5, 1.5)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _in_band(v: float, lo: float, hi: float) -> bool:
|
|
580
|
+
return lo <= v <= hi
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _check_geometry_review(doc: Document, spec: DocSpec | None,
|
|
584
|
+
profile: Profile) -> list[str]:
|
|
585
|
+
"""Review-режим: геометрия как warnings (полосы реальной судебной практики)."""
|
|
586
|
+
warnings: list[str] = []
|
|
587
|
+
lo, hi = REVIEW_MARGIN_BAND_CM
|
|
588
|
+
section = doc.sections[0]
|
|
589
|
+
for name, actual in (("left", _cm(section.left_margin)),
|
|
590
|
+
("right", _cm(section.right_margin)),
|
|
591
|
+
("top", _cm(section.top_margin)),
|
|
592
|
+
("bottom", _cm(section.bottom_margin))):
|
|
593
|
+
if not _in_band(actual, lo, hi):
|
|
594
|
+
warnings.append(
|
|
595
|
+
f"review_margin_{name}: {actual:.3f}cm вне полосы {lo}-{hi}cm")
|
|
596
|
+
f_lo, f_hi = REVIEW_FONT_BAND_PT
|
|
597
|
+
for is_cell, p_idx, p in _iter_all_paragraphs(doc):
|
|
598
|
+
for run in p.runs:
|
|
599
|
+
if not run.text:
|
|
600
|
+
continue
|
|
601
|
+
if run.font.name and run.font.name != profile.font_name:
|
|
602
|
+
warnings.append(
|
|
603
|
+
f"review_font_name: paragraph {p_idx} run uses '{run.font.name}'")
|
|
604
|
+
size = _pt(run.font.size)
|
|
605
|
+
if size is not None and not _in_band(size, f_lo, f_hi):
|
|
606
|
+
warnings.append(
|
|
607
|
+
f"review_font_size: paragraph {p_idx} run size {size:.1f}pt "
|
|
608
|
+
f"вне полосы {f_lo:.0f}-{f_hi:.0f}pt")
|
|
609
|
+
s_lo, s_hi = REVIEW_LINE_SPACING_BAND
|
|
610
|
+
i_lo, i_hi = REVIEW_FIRST_LINE_BAND_CM
|
|
611
|
+
roles = _classify_paragraphs(doc, spec)
|
|
612
|
+
for p_idx, p in enumerate(doc.paragraphs):
|
|
613
|
+
if roles.get(p_idx) != "body":
|
|
614
|
+
continue
|
|
615
|
+
pf = p.paragraph_format
|
|
616
|
+
ls = pf.line_spacing
|
|
617
|
+
if isinstance(ls, (int, float)) and not _in_band(float(ls), s_lo, s_hi):
|
|
618
|
+
warnings.append(
|
|
619
|
+
f"review_line_spacing: paragraph {p_idx} = {float(ls):.2f} "
|
|
620
|
+
f"вне полосы {s_lo}-{s_hi}")
|
|
621
|
+
ind = _cm(pf.first_line_indent)
|
|
622
|
+
if _in_band(ind, i_lo, i_hi):
|
|
623
|
+
continue
|
|
624
|
+
if ind == 0.0:
|
|
625
|
+
# Отсутствие красной строки легитимно при явном межпараграфном
|
|
626
|
+
# интервале (полоса, согласованная с внешним рецензентом).
|
|
627
|
+
after = pf.space_after.pt if pf.space_after is not None else 0.0
|
|
628
|
+
before = pf.space_before.pt if pf.space_before is not None else 0.0
|
|
629
|
+
if max(after, before) <= 0:
|
|
630
|
+
warnings.append(
|
|
631
|
+
f"review_first_line_indent: paragraph {p_idx} — отступ "
|
|
632
|
+
f"отсутствует и межпараграфный интервал не задан")
|
|
633
|
+
else:
|
|
634
|
+
warnings.append(
|
|
635
|
+
f"review_first_line_indent: paragraph {p_idx} = {ind:.3f}cm "
|
|
636
|
+
f"вне полосы {i_lo}-{i_hi}cm")
|
|
637
|
+
return warnings
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def validate_document(path: str, spec: DocSpec | None = None,
|
|
641
|
+
mode: str = "gost") -> dict[str, Any]:
|
|
642
|
+
"""Reopen generated .docx and run all built-in checks.
|
|
643
|
+
|
|
644
|
+
mode='gost' (default) — строгая геометрия по профилю (собственные
|
|
645
|
+
процессуальные документы), прежнее поведение без изменений.
|
|
646
|
+
mode='review' — входящие акты судов/оппонентов: геометрия даёт только
|
|
647
|
+
warnings (полосы реальной практики), errors остаются строго контентными
|
|
648
|
+
(фикс 5, согласовано с внешним рецензентом).
|
|
649
|
+
"""
|
|
650
|
+
if mode not in ("gost", "review"):
|
|
651
|
+
raise ValueError(f"unknown validate mode: {mode!r} (expected: gost|review)")
|
|
652
|
+
# Standalone mode: guess the profile from the document itself so court
|
|
653
|
+
# documents are checked against court rules, not gost.
|
|
654
|
+
effective_profile_name = spec.profile if spec else None
|
|
655
|
+
if effective_profile_name is None:
|
|
656
|
+
try:
|
|
657
|
+
from docx import Document as _Document
|
|
658
|
+
effective_profile_name = _guess_profile(_Document(path))
|
|
659
|
+
except Exception:
|
|
660
|
+
effective_profile_name = "gost"
|
|
661
|
+
profile = Profile(
|
|
662
|
+
effective_profile_name,
|
|
663
|
+
line_spacing=spec.line_spacing if spec else None,
|
|
664
|
+
)
|
|
665
|
+
errors: list[str] = []
|
|
666
|
+
warnings: list[str] = []
|
|
667
|
+
checks: dict[str, str] = {}
|
|
668
|
+
|
|
669
|
+
try:
|
|
670
|
+
doc = Document(path)
|
|
671
|
+
except Exception as exc:
|
|
672
|
+
return {
|
|
673
|
+
"checks": {"open": "fail"},
|
|
674
|
+
"warnings": [],
|
|
675
|
+
"errors": [f"open_failed: {exc}"],
|
|
676
|
+
"skipped": [],
|
|
677
|
+
"skips": [],
|
|
678
|
+
"findings": [],
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
skipped: list = []
|
|
682
|
+
try:
|
|
683
|
+
_classify_paragraphs(doc, spec, profile, skipped=skipped)
|
|
684
|
+
except Exception:
|
|
685
|
+
pass
|
|
686
|
+
# de-duplicate identical (zone, reason) records collected across passes
|
|
687
|
+
_seen = set()
|
|
688
|
+
_uniq = []
|
|
689
|
+
for _f in skipped:
|
|
690
|
+
_key = (_f.get("zone"), _f.get("reason"))
|
|
691
|
+
if _key in _seen:
|
|
692
|
+
continue
|
|
693
|
+
_seen.add(_key)
|
|
694
|
+
_uniq.append(_f)
|
|
695
|
+
skipped = _uniq
|
|
696
|
+
|
|
697
|
+
pi_errors = _check_package_integrity(path)
|
|
698
|
+
errors.extend(pi_errors)
|
|
699
|
+
checks["package_integrity"] = "fail" if pi_errors else "pass"
|
|
700
|
+
|
|
701
|
+
if mode == "review":
|
|
702
|
+
# v0.5.3 фикс 5: геометрия — только warnings; строгие профильные
|
|
703
|
+
# проверки (margins/fonts/body/list/header/pleading geometry) не
|
|
704
|
+
# блокируют review входящего документа.
|
|
705
|
+
geom_warnings = _check_geometry_review(doc, spec, profile)
|
|
706
|
+
warnings.extend(geom_warnings)
|
|
707
|
+
checks["margins"] = "warn" if any(
|
|
708
|
+
w.startswith("review_margin") for w in geom_warnings) else "pass"
|
|
709
|
+
checks["fonts"] = "warn" if any(
|
|
710
|
+
w.startswith("review_font") for w in geom_warnings) else "pass"
|
|
711
|
+
checks["body_formatting"] = "warn" if any(
|
|
712
|
+
w.startswith(("review_line_spacing", "review_first_line"))
|
|
713
|
+
for w in geom_warnings) else "pass"
|
|
714
|
+
checks["list_formatting"] = "n/a"
|
|
715
|
+
checks["header_formatting"] = "n/a"
|
|
716
|
+
checks["pleading_marker"] = "n/a"
|
|
717
|
+
else:
|
|
718
|
+
margin_errors = _check_margins(doc, profile)
|
|
719
|
+
errors.extend(margin_errors)
|
|
720
|
+
checks["margins"] = "fail" if margin_errors else "pass"
|
|
721
|
+
|
|
722
|
+
guessed = effective_profile_name
|
|
723
|
+
font_errors = _check_fonts_and_sizes(doc, spec, guessed_profile=guessed)
|
|
724
|
+
errors.extend(font_errors)
|
|
725
|
+
checks["fonts"] = "fail" if font_errors else "pass"
|
|
726
|
+
|
|
727
|
+
fmt_errors = _check_body_formatting(doc, spec, guessed_profile=guessed)
|
|
728
|
+
errors.extend(fmt_errors)
|
|
729
|
+
checks["body_formatting"] = "fail" if fmt_errors else "pass"
|
|
730
|
+
|
|
731
|
+
list_errors = _check_list_formatting(doc, spec, profile)
|
|
732
|
+
errors.extend(list_errors)
|
|
733
|
+
checks["list_formatting"] = "fail" if list_errors else "pass"
|
|
734
|
+
|
|
735
|
+
header_errors = _check_header_formatting(doc, spec, profile)
|
|
736
|
+
errors.extend(header_errors)
|
|
737
|
+
checks["header_formatting"] = "fail" if header_errors else "pass"
|
|
738
|
+
|
|
739
|
+
pleading_errors, pleading_warnings = _check_pleading_marker(doc, spec, profile)
|
|
740
|
+
errors.extend(pleading_errors)
|
|
741
|
+
warnings.extend(pleading_warnings)
|
|
742
|
+
checks["pleading_marker"] = "fail" if pleading_errors else ("warn" if pleading_warnings else "pass")
|
|
743
|
+
|
|
744
|
+
en_errors, en_warnings = _check_en_dash(doc, spec)
|
|
745
|
+
errors.extend(en_errors)
|
|
746
|
+
warnings.extend(en_warnings)
|
|
747
|
+
checks["no_en_dash"] = "fail" if en_errors else ("warn" if en_warnings else "pass")
|
|
748
|
+
|
|
749
|
+
yo_warnings = _check_yo(doc, spec)
|
|
750
|
+
warnings.extend(yo_warnings)
|
|
751
|
+
checks["no_yo"] = "warn" if yo_warnings else "pass"
|
|
752
|
+
|
|
753
|
+
ph_findings = _check_placeholders(doc, spec)
|
|
754
|
+
if mode == "review":
|
|
755
|
+
# Фикс 5: остаточные плейсхолдеры — контентный error и в review.
|
|
756
|
+
errors.extend(ph_findings)
|
|
757
|
+
checks["placeholders"] = "fail" if ph_findings else "pass"
|
|
758
|
+
else:
|
|
759
|
+
warnings.extend(ph_findings)
|
|
760
|
+
checks["placeholders"] = "warn" if ph_findings else "pass"
|
|
761
|
+
|
|
762
|
+
norm_findings = _check_norm_refs(doc, spec)
|
|
763
|
+
if mode == "review":
|
|
764
|
+
# Фикс 5: отсутствие ссылок на нормы (когда проверка активна) —
|
|
765
|
+
# контентный error и в review.
|
|
766
|
+
errors.extend(norm_findings)
|
|
767
|
+
checks["norm_refs"] = "fail" if norm_findings else "pass"
|
|
768
|
+
else:
|
|
769
|
+
warnings.extend(norm_findings)
|
|
770
|
+
checks["norm_refs"] = "warn" if norm_findings else "pass"
|
|
771
|
+
|
|
772
|
+
seq_errors = _check_numbered_list_sequence(doc, spec)
|
|
773
|
+
errors.extend(seq_errors)
|
|
774
|
+
checks["numbered_list_sequence"] = "fail" if seq_errors else "pass"
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
"validated_at": now_iso(),
|
|
778
|
+
"mode": mode,
|
|
779
|
+
"profile": effective_profile_name,
|
|
780
|
+
"checks": checks,
|
|
781
|
+
"warnings": warnings,
|
|
782
|
+
"errors": errors,
|
|
783
|
+
"skipped": skipped,
|
|
784
|
+
"skips": skipped,
|
|
785
|
+
"findings": skipped,
|
|
786
|
+
}
|