cadence-diff 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.
- cadence_diff-0.1.0.dist-info/METADATA +245 -0
- cadence_diff-0.1.0.dist-info/RECORD +78 -0
- cadence_diff-0.1.0.dist-info/WHEEL +4 -0
- cadence_diff-0.1.0.dist-info/entry_points.txt +3 -0
- cadence_diff-0.1.0.dist-info/licenses/LICENSE +190 -0
- qc_tool/__init__.py +3 -0
- qc_tool/__main__.py +6 -0
- qc_tool/attestation.py +209 -0
- qc_tool/availability.py +273 -0
- qc_tool/cli.py +769 -0
- qc_tool/config/__init__.py +1 -0
- qc_tool/config/lint.py +480 -0
- qc_tool/config/profile.py +207 -0
- qc_tool/coverage.py +35 -0
- qc_tool/crosscheck/__init__.py +1 -0
- qc_tool/crosscheck/numbers.py +146 -0
- qc_tool/crosscheck/package.py +154 -0
- qc_tool/crosscheck/trace.py +374 -0
- qc_tool/engine.py +978 -0
- qc_tool/excel/__init__.py +1 -0
- qc_tool/excel/align.py +452 -0
- qc_tool/excel/charts.py +931 -0
- qc_tool/excel/context.py +106 -0
- qc_tool/excel/controls.py +266 -0
- qc_tool/excel/dependency.py +252 -0
- qc_tool/excel/diff_structure.py +326 -0
- qc_tool/excel/diff_values.py +327 -0
- qc_tool/excel/formulas.py +516 -0
- qc_tool/excel/interaction.py +548 -0
- qc_tool/excel/periods.py +117 -0
- qc_tool/excel/preflight.py +615 -0
- qc_tool/excel/references.py +436 -0
- qc_tool/excel/regions.py +201 -0
- qc_tool/findings.py +220 -0
- qc_tool/fingerprint.py +369 -0
- qc_tool/fingerprint.schema.json +15 -0
- qc_tool/history/__init__.py +1 -0
- qc_tool/history/store.py +287 -0
- qc_tool/io/__init__.py +1 -0
- qc_tool/io/decrypt.py +64 -0
- qc_tool/io/excel_formula.py +256 -0
- qc_tool/io/excel_formula_worker.py +280 -0
- qc_tool/io/formula_enrichment.py +95 -0
- qc_tool/io/libreoffice_formula.py +232 -0
- qc_tool/io/loader.py +973 -0
- qc_tool/io/model.py +356 -0
- qc_tool/io/ooxml_chart.py +530 -0
- qc_tool/io/ooxml_interaction.py +400 -0
- qc_tool/io/ooxml_worksheet.py +405 -0
- qc_tool/io/xlsb_formula.py +313 -0
- qc_tool/package-sanitize.schema.json +31 -0
- qc_tool/package_sanitize.py +255 -0
- qc_tool/ppt/__init__.py +1 -0
- qc_tool/ppt/chart_xml.py +422 -0
- qc_tool/ppt/diff.py +120 -0
- qc_tool/ppt/element_diff.py +878 -0
- qc_tool/ppt/element_match.py +317 -0
- qc_tool/ppt/extract.py +212 -0
- qc_tool/ppt/match.py +137 -0
- qc_tool/ppt/model.py +132 -0
- qc_tool/ppt/preflight.py +307 -0
- qc_tool/privacy.py +392 -0
- qc_tool/progress.py +78 -0
- qc_tool/report/__init__.py +1 -0
- qc_tool/report/excel_report.py +205 -0
- qc_tool/report/findings.schema.json +32 -0
- qc_tool/report/html_report.py +38 -0
- qc_tool/report/json_report.py +55 -0
- qc_tool/report/templates/report.html.j2 +117 -0
- qc_tool/sanitize.py +598 -0
- qc_tool/security.py +34 -0
- qc_tool/server_config.py +86 -0
- qc_tool/triage/__init__.py +1 -0
- qc_tool/triage/rules.py +211 -0
- qc_tool/ui/__init__.py +1 -0
- qc_tool/ui/app.py +1261 -0
- qc_tool/ui/guide.py +452 -0
- qc_tool/ui/theme.py +505 -0
qc_tool/engine.py
ADDED
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
"""QC run orchestrator: load, align, diff, cross-check, triage.
|
|
2
|
+
|
|
3
|
+
One `run_qc` call is one QC run — a baseline/current workbook pair and/or
|
|
4
|
+
a baseline/current deck pair, an optional profile, and per-file passwords.
|
|
5
|
+
The result carries triaged findings plus capability disclosures, including
|
|
6
|
+
whether XLSB formula text was enriched or only formula presence was checked.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from qc_tool.availability import (
|
|
14
|
+
availability_coverage,
|
|
15
|
+
excel_availability_issues,
|
|
16
|
+
ppt_availability_issues,
|
|
17
|
+
)
|
|
18
|
+
from qc_tool.config.profile import DeliverableProfile, default_profile
|
|
19
|
+
from qc_tool.coverage import CoverageItem, CoverageState, MappingCoverage, QCRunMode
|
|
20
|
+
from qc_tool.crosscheck.package import reconcile_package
|
|
21
|
+
from qc_tool.crosscheck.trace import (
|
|
22
|
+
MappingSuggestion,
|
|
23
|
+
annotate_ppt_chart_impacts,
|
|
24
|
+
verify_mappings,
|
|
25
|
+
)
|
|
26
|
+
from qc_tool.excel.align import align_workbooks
|
|
27
|
+
from qc_tool.excel.charts import annotate_chart_impacts, chart_reference_coverage
|
|
28
|
+
from qc_tool.excel.context import attach_current_excerpts, attach_excerpts
|
|
29
|
+
from qc_tool.excel.controls import evaluate_controls
|
|
30
|
+
from qc_tool.excel.dependency import (
|
|
31
|
+
DependencyGraph,
|
|
32
|
+
annotate_impacts,
|
|
33
|
+
build_dependency_graph,
|
|
34
|
+
limit_impacts,
|
|
35
|
+
)
|
|
36
|
+
from qc_tool.excel.diff_structure import diff_workbook_structure
|
|
37
|
+
from qc_tool.excel.diff_values import diff_workbook_values
|
|
38
|
+
from qc_tool.excel.formulas import diff_workbook_formulas, formula_text_compatible
|
|
39
|
+
from qc_tool.excel.interaction import (
|
|
40
|
+
conditional_style_coverage,
|
|
41
|
+
interaction_rule_coverage,
|
|
42
|
+
)
|
|
43
|
+
from qc_tool.excel.preflight import preflight_workbook
|
|
44
|
+
from qc_tool.findings import Finding, FindingClass, Severity, limit_findings
|
|
45
|
+
from qc_tool.io.loader import load_workbook_snapshot
|
|
46
|
+
from qc_tool.io.model import WorkbookSnapshot
|
|
47
|
+
from qc_tool.ppt.diff import diff_decks
|
|
48
|
+
from qc_tool.ppt.extract import load_deck_snapshot
|
|
49
|
+
from qc_tool.ppt.match import match_slides
|
|
50
|
+
from qc_tool.ppt.preflight import preflight_deck
|
|
51
|
+
from qc_tool.progress import (
|
|
52
|
+
CancellationToken,
|
|
53
|
+
ProgressCallback,
|
|
54
|
+
RunPhase,
|
|
55
|
+
check_cancelled,
|
|
56
|
+
report_progress,
|
|
57
|
+
)
|
|
58
|
+
from qc_tool.triage.rules import triage
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_excel_file(
|
|
64
|
+
path: Path,
|
|
65
|
+
*,
|
|
66
|
+
password: str | None,
|
|
67
|
+
phase: RunPhase,
|
|
68
|
+
allow_large_workbooks: bool,
|
|
69
|
+
cancellation_token: CancellationToken | None,
|
|
70
|
+
on_progress: ProgressCallback | None,
|
|
71
|
+
) -> WorkbookSnapshot:
|
|
72
|
+
check_cancelled(cancellation_token)
|
|
73
|
+
report_progress(on_progress, phase, total=1, detail=path.name)
|
|
74
|
+
workbook = load_workbook_snapshot(
|
|
75
|
+
path,
|
|
76
|
+
password=password,
|
|
77
|
+
allow_large_workbook=allow_large_workbooks,
|
|
78
|
+
cancellation_token=cancellation_token,
|
|
79
|
+
)
|
|
80
|
+
check_cancelled(cancellation_token)
|
|
81
|
+
report_progress(on_progress, phase, processed=1, total=1, detail=path.name)
|
|
82
|
+
return workbook
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_powerpoint_file(
|
|
86
|
+
path: Path,
|
|
87
|
+
*,
|
|
88
|
+
password: str | None,
|
|
89
|
+
phase: RunPhase,
|
|
90
|
+
cancellation_token: CancellationToken | None,
|
|
91
|
+
on_progress: ProgressCallback | None,
|
|
92
|
+
):
|
|
93
|
+
check_cancelled(cancellation_token)
|
|
94
|
+
report_progress(on_progress, phase, total=1, detail=path.name)
|
|
95
|
+
deck = load_deck_snapshot(
|
|
96
|
+
path,
|
|
97
|
+
password=password,
|
|
98
|
+
cancellation_token=cancellation_token,
|
|
99
|
+
)
|
|
100
|
+
check_cancelled(cancellation_token)
|
|
101
|
+
report_progress(on_progress, phase, processed=1, total=1, detail=path.name)
|
|
102
|
+
return deck
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _workload_coverage(*workbooks: WorkbookSnapshot) -> CoverageItem:
|
|
106
|
+
ooxml = [
|
|
107
|
+
workbook
|
|
108
|
+
for workbook in workbooks
|
|
109
|
+
if workbook.file_format in {"xlsx", "xlsm"}
|
|
110
|
+
]
|
|
111
|
+
if not ooxml:
|
|
112
|
+
return CoverageItem(
|
|
113
|
+
check_id="excel-workload",
|
|
114
|
+
label="Excel workload safeguards",
|
|
115
|
+
artifact="excel",
|
|
116
|
+
state=CoverageState.UNAVAILABLE,
|
|
117
|
+
detail="OOXML workload metrics are unavailable for XLSB",
|
|
118
|
+
)
|
|
119
|
+
incomplete = len(ooxml) != len(workbooks)
|
|
120
|
+
degraded = incomplete or any(workbook.workload.degraded for workbook in ooxml)
|
|
121
|
+
details = [
|
|
122
|
+
f"{workbook.source_name}: {workbook.workload.detail}"
|
|
123
|
+
for workbook in ooxml
|
|
124
|
+
]
|
|
125
|
+
if incomplete:
|
|
126
|
+
details.append("XLSB workload metrics unavailable for one workbook")
|
|
127
|
+
return CoverageItem(
|
|
128
|
+
check_id="excel-workload",
|
|
129
|
+
label="Excel workload safeguards",
|
|
130
|
+
artifact="excel",
|
|
131
|
+
state=CoverageState.DEGRADED if degraded else CoverageState.CHECKED,
|
|
132
|
+
detail="; ".join(details),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _apply_findings_budget(
|
|
137
|
+
findings: list[Finding],
|
|
138
|
+
coverage: list[CoverageItem],
|
|
139
|
+
) -> list[Finding]:
|
|
140
|
+
budget = limit_findings(findings)
|
|
141
|
+
if not budget.omitted_by_artifact:
|
|
142
|
+
return budget.findings
|
|
143
|
+
affected_artifacts = set(budget.omitted_by_artifact)
|
|
144
|
+
if "crosscheck" in affected_artifacts:
|
|
145
|
+
affected_artifacts.add("package")
|
|
146
|
+
if budget.global_omitted:
|
|
147
|
+
affected_artifacts.update(item.artifact for item in coverage)
|
|
148
|
+
omitted_total = sum(budget.omitted_by_artifact.values())
|
|
149
|
+
detail = (
|
|
150
|
+
f"Output budget omitted {omitted_total} findings; summary finding(s) "
|
|
151
|
+
"identify affected classes and scopes"
|
|
152
|
+
)
|
|
153
|
+
for item in coverage:
|
|
154
|
+
if item.artifact not in affected_artifacts:
|
|
155
|
+
continue
|
|
156
|
+
if item.state is CoverageState.CHECKED:
|
|
157
|
+
item.state = CoverageState.DEGRADED
|
|
158
|
+
item.detail = f"{item.detail}; {detail}" if item.detail else detail
|
|
159
|
+
return budget.findings
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _pair_coverage_state(
|
|
163
|
+
baseline: CoverageState,
|
|
164
|
+
current: CoverageState,
|
|
165
|
+
) -> CoverageState:
|
|
166
|
+
if CoverageState.UNAVAILABLE in {baseline, current}:
|
|
167
|
+
return CoverageState.UNAVAILABLE
|
|
168
|
+
if CoverageState.DEGRADED in {baseline, current}:
|
|
169
|
+
return CoverageState.DEGRADED
|
|
170
|
+
return CoverageState.CHECKED
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _single_formula_disclosure(workbook: WorkbookSnapshot) -> str | None:
|
|
174
|
+
if getattr(workbook, "formulas_available", False):
|
|
175
|
+
return None
|
|
176
|
+
if getattr(workbook, "formula_presence_available", False):
|
|
177
|
+
return (
|
|
178
|
+
"Formula QC is degraded: formula-record presence was checked, so missing "
|
|
179
|
+
"formulas and saved error values were evaluated. Formula text was unavailable, "
|
|
180
|
+
"so logic, consistency, range-extension semantics, and error references inside "
|
|
181
|
+
"formula text could not be evaluated."
|
|
182
|
+
)
|
|
183
|
+
return (
|
|
184
|
+
"Formula QC is degraded: neither reliable formula text nor formula-record "
|
|
185
|
+
"presence was available; only saved error values were evaluated."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _pair_formula_disclosure(
|
|
190
|
+
base_wb: WorkbookSnapshot, curr_wb: WorkbookSnapshot
|
|
191
|
+
) -> str | None:
|
|
192
|
+
if formula_text_compatible(base_wb, curr_wb):
|
|
193
|
+
return None
|
|
194
|
+
if getattr(base_wb, "formula_presence_available", False) and getattr(
|
|
195
|
+
curr_wb, "formula_presence_available", False
|
|
196
|
+
):
|
|
197
|
+
return (
|
|
198
|
+
"Formula QC is degraded for this pair: formula-record presence was checked, "
|
|
199
|
+
"so hardcoding, removals, and missing fill formulas were evaluated. Compatible "
|
|
200
|
+
"formula text was unavailable, so logic changes, consistency, range-extension "
|
|
201
|
+
"semantics, and error references inside formula text could not be evaluated."
|
|
202
|
+
)
|
|
203
|
+
return (
|
|
204
|
+
"Formula QC is degraded for this pair: reliable formula-record presence was "
|
|
205
|
+
"unavailable for one or both workbooks; only saved error values were evaluated."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(slots=True)
|
|
210
|
+
class QCRunResult:
|
|
211
|
+
profile_name: str
|
|
212
|
+
mode: QCRunMode = QCRunMode.CYCLE_COMPARISON
|
|
213
|
+
files: dict[str, str] = field(default_factory=dict) # role -> file name
|
|
214
|
+
findings: list[Finding] = field(default_factory=list)
|
|
215
|
+
disclosures: list[str] = field(default_factory=list)
|
|
216
|
+
coverage: list[CoverageItem] = field(default_factory=list)
|
|
217
|
+
mapping_coverage: MappingCoverage | None = None
|
|
218
|
+
mapping_suggestions: list[MappingSuggestion] = field(default_factory=list)
|
|
219
|
+
verified_crosschecks: int = 0
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def counts(self) -> dict[Severity, int]:
|
|
223
|
+
result = dict.fromkeys(Severity, 0)
|
|
224
|
+
for finding in self.findings:
|
|
225
|
+
if finding.severity is not None:
|
|
226
|
+
result[finding.severity] += 1
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def run_qc(
|
|
231
|
+
*,
|
|
232
|
+
baseline_excel: Path | None = None,
|
|
233
|
+
current_excel: Path | None = None,
|
|
234
|
+
baseline_ppt: Path | None = None,
|
|
235
|
+
current_ppt: Path | None = None,
|
|
236
|
+
profile: DeliverableProfile | None = None,
|
|
237
|
+
passwords: dict[str, str] | None = None,
|
|
238
|
+
mode: QCRunMode = QCRunMode.CYCLE_COMPARISON,
|
|
239
|
+
allow_large_workbooks: bool = False,
|
|
240
|
+
cancellation_token: CancellationToken | None = None,
|
|
241
|
+
on_progress: ProgressCallback | None = None,
|
|
242
|
+
) -> QCRunResult:
|
|
243
|
+
"""Run a full QC comparison. ``passwords`` is keyed by file name."""
|
|
244
|
+
check_cancelled(cancellation_token)
|
|
245
|
+
mode = QCRunMode(mode)
|
|
246
|
+
report_progress(
|
|
247
|
+
on_progress,
|
|
248
|
+
RunPhase.PREPARING,
|
|
249
|
+
processed=1,
|
|
250
|
+
total=1,
|
|
251
|
+
detail=mode.value,
|
|
252
|
+
)
|
|
253
|
+
if mode is QCRunMode.CURRENT_FILE_PREFLIGHT:
|
|
254
|
+
if baseline_excel is not None or baseline_ppt is not None:
|
|
255
|
+
raise ValueError("current-file preflight does not accept baseline files")
|
|
256
|
+
if current_excel is None and current_ppt is None:
|
|
257
|
+
raise ValueError("current-file preflight needs a current Excel or PowerPoint file")
|
|
258
|
+
profile = profile or default_profile()
|
|
259
|
+
passwords = passwords or {}
|
|
260
|
+
result = QCRunResult(profile_name=profile.name, mode=mode)
|
|
261
|
+
findings: list[Finding] = []
|
|
262
|
+
if current_excel is not None:
|
|
263
|
+
workbook = _load_excel_file(
|
|
264
|
+
current_excel,
|
|
265
|
+
password=passwords.get(current_excel.name),
|
|
266
|
+
phase=RunPhase.LOADING_CURRENT_EXCEL,
|
|
267
|
+
allow_large_workbooks=allow_large_workbooks,
|
|
268
|
+
cancellation_token=cancellation_token,
|
|
269
|
+
on_progress=on_progress,
|
|
270
|
+
)
|
|
271
|
+
result.files["current_excel"] = current_excel.name
|
|
272
|
+
report_progress(on_progress, RunPhase.ANALYZING_EXCEL, total=1)
|
|
273
|
+
excel_preflight = preflight_workbook(
|
|
274
|
+
workbook,
|
|
275
|
+
profile,
|
|
276
|
+
cancellation_token=cancellation_token,
|
|
277
|
+
)
|
|
278
|
+
check_cancelled(cancellation_token)
|
|
279
|
+
report_progress(
|
|
280
|
+
on_progress,
|
|
281
|
+
RunPhase.ANALYZING_EXCEL,
|
|
282
|
+
processed=1,
|
|
283
|
+
total=1,
|
|
284
|
+
)
|
|
285
|
+
attach_current_excerpts(excel_preflight.findings, workbook)
|
|
286
|
+
findings.extend(excel_preflight.findings)
|
|
287
|
+
result.coverage.extend(excel_preflight.coverage)
|
|
288
|
+
result.coverage.append(_workload_coverage(workbook))
|
|
289
|
+
if disclosure := _single_formula_disclosure(workbook):
|
|
290
|
+
result.disclosures.append(disclosure)
|
|
291
|
+
else:
|
|
292
|
+
result.coverage.append(
|
|
293
|
+
CoverageItem(
|
|
294
|
+
check_id="excel-intrinsic",
|
|
295
|
+
label="Current Excel intrinsic checks",
|
|
296
|
+
artifact="excel",
|
|
297
|
+
state=CoverageState.UNAVAILABLE,
|
|
298
|
+
detail="No current workbook supplied",
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
if current_ppt is not None:
|
|
302
|
+
deck = _load_powerpoint_file(
|
|
303
|
+
current_ppt,
|
|
304
|
+
password=passwords.get(current_ppt.name),
|
|
305
|
+
phase=RunPhase.LOADING_CURRENT_POWERPOINT,
|
|
306
|
+
cancellation_token=cancellation_token,
|
|
307
|
+
on_progress=on_progress,
|
|
308
|
+
)
|
|
309
|
+
result.files["current_ppt"] = current_ppt.name
|
|
310
|
+
report_progress(on_progress, RunPhase.ANALYZING_POWERPOINT, total=1)
|
|
311
|
+
ppt_preflight = preflight_deck(deck, profile.ppt)
|
|
312
|
+
check_cancelled(cancellation_token)
|
|
313
|
+
report_progress(
|
|
314
|
+
on_progress,
|
|
315
|
+
RunPhase.ANALYZING_POWERPOINT,
|
|
316
|
+
processed=1,
|
|
317
|
+
total=1,
|
|
318
|
+
)
|
|
319
|
+
findings.extend(ppt_preflight.findings)
|
|
320
|
+
result.coverage.extend(ppt_preflight.coverage)
|
|
321
|
+
else:
|
|
322
|
+
result.coverage.append(
|
|
323
|
+
CoverageItem(
|
|
324
|
+
check_id="ppt-intrinsic",
|
|
325
|
+
label="Current PowerPoint intrinsic checks",
|
|
326
|
+
artifact="ppt",
|
|
327
|
+
state=CoverageState.UNAVAILABLE,
|
|
328
|
+
detail="No current deck supplied",
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
result.coverage.extend(
|
|
332
|
+
[
|
|
333
|
+
CoverageItem(
|
|
334
|
+
check_id="excel-cycle-comparison",
|
|
335
|
+
label="Historical Excel changes",
|
|
336
|
+
artifact="excel",
|
|
337
|
+
state=CoverageState.UNAVAILABLE,
|
|
338
|
+
detail="No baseline workbook supplied",
|
|
339
|
+
),
|
|
340
|
+
CoverageItem(
|
|
341
|
+
check_id="ppt-cycle-comparison",
|
|
342
|
+
label="Historical PowerPoint changes",
|
|
343
|
+
artifact="ppt",
|
|
344
|
+
state=CoverageState.UNAVAILABLE,
|
|
345
|
+
detail="No baseline deck supplied",
|
|
346
|
+
),
|
|
347
|
+
CoverageItem(
|
|
348
|
+
check_id="excel-ppt-crosscheck",
|
|
349
|
+
label="Excel to PowerPoint mappings",
|
|
350
|
+
artifact="package",
|
|
351
|
+
state=CoverageState.UNAVAILABLE,
|
|
352
|
+
detail="Use final-package mode for current Excel-to-PowerPoint QC",
|
|
353
|
+
),
|
|
354
|
+
]
|
|
355
|
+
)
|
|
356
|
+
findings = _apply_findings_budget(findings, result.coverage)
|
|
357
|
+
result.findings = triage(findings, profile)
|
|
358
|
+
return result
|
|
359
|
+
if mode is QCRunMode.FINAL_PACKAGE:
|
|
360
|
+
if baseline_excel is not None or baseline_ppt is not None:
|
|
361
|
+
raise ValueError("final-package QC does not accept baseline files")
|
|
362
|
+
if current_excel is None or current_ppt is None:
|
|
363
|
+
raise ValueError("final-package QC needs current Excel and PowerPoint files")
|
|
364
|
+
profile = profile or default_profile()
|
|
365
|
+
passwords = passwords or {}
|
|
366
|
+
workbook = _load_excel_file(
|
|
367
|
+
current_excel,
|
|
368
|
+
password=passwords.get(current_excel.name),
|
|
369
|
+
phase=RunPhase.LOADING_CURRENT_EXCEL,
|
|
370
|
+
allow_large_workbooks=allow_large_workbooks,
|
|
371
|
+
cancellation_token=cancellation_token,
|
|
372
|
+
on_progress=on_progress,
|
|
373
|
+
)
|
|
374
|
+
deck = _load_powerpoint_file(
|
|
375
|
+
current_ppt,
|
|
376
|
+
password=passwords.get(current_ppt.name),
|
|
377
|
+
phase=RunPhase.LOADING_CURRENT_POWERPOINT,
|
|
378
|
+
cancellation_token=cancellation_token,
|
|
379
|
+
on_progress=on_progress,
|
|
380
|
+
)
|
|
381
|
+
result = QCRunResult(profile_name=profile.name, mode=mode)
|
|
382
|
+
result.files = {
|
|
383
|
+
"current_excel": current_excel.name,
|
|
384
|
+
"current_ppt": current_ppt.name,
|
|
385
|
+
}
|
|
386
|
+
findings: list[Finding] = []
|
|
387
|
+
report_progress(on_progress, RunPhase.ANALYZING_EXCEL, total=1)
|
|
388
|
+
excel_preflight = preflight_workbook(
|
|
389
|
+
workbook,
|
|
390
|
+
profile,
|
|
391
|
+
cancellation_token=cancellation_token,
|
|
392
|
+
)
|
|
393
|
+
check_cancelled(cancellation_token)
|
|
394
|
+
report_progress(
|
|
395
|
+
on_progress,
|
|
396
|
+
RunPhase.ANALYZING_EXCEL,
|
|
397
|
+
processed=1,
|
|
398
|
+
total=1,
|
|
399
|
+
)
|
|
400
|
+
report_progress(on_progress, RunPhase.ANALYZING_POWERPOINT, total=1)
|
|
401
|
+
ppt_preflight = preflight_deck(deck, profile.ppt)
|
|
402
|
+
check_cancelled(cancellation_token)
|
|
403
|
+
report_progress(
|
|
404
|
+
on_progress,
|
|
405
|
+
RunPhase.ANALYZING_POWERPOINT,
|
|
406
|
+
processed=1,
|
|
407
|
+
total=1,
|
|
408
|
+
)
|
|
409
|
+
report_progress(on_progress, RunPhase.CROSSCHECKING, total=1)
|
|
410
|
+
package = reconcile_package(workbook, deck, profile.crosscheck)
|
|
411
|
+
check_cancelled(cancellation_token)
|
|
412
|
+
report_progress(
|
|
413
|
+
on_progress,
|
|
414
|
+
RunPhase.CROSSCHECKING,
|
|
415
|
+
processed=1,
|
|
416
|
+
total=1,
|
|
417
|
+
)
|
|
418
|
+
attach_current_excerpts(excel_preflight.findings, workbook)
|
|
419
|
+
findings.extend(excel_preflight.findings)
|
|
420
|
+
findings.extend(ppt_preflight.findings)
|
|
421
|
+
findings.extend(package.findings)
|
|
422
|
+
if excel_preflight.dependency_graph is not None:
|
|
423
|
+
annotate_ppt_chart_impacts(
|
|
424
|
+
findings,
|
|
425
|
+
deck,
|
|
426
|
+
profile.crosscheck,
|
|
427
|
+
excel_preflight.dependency_graph,
|
|
428
|
+
)
|
|
429
|
+
limit_impacts(findings)
|
|
430
|
+
result.coverage = [
|
|
431
|
+
*excel_preflight.coverage,
|
|
432
|
+
_workload_coverage(workbook),
|
|
433
|
+
*ppt_preflight.coverage,
|
|
434
|
+
*package.coverage,
|
|
435
|
+
CoverageItem(
|
|
436
|
+
check_id="cycle-comparison",
|
|
437
|
+
label="Historical cycle comparison",
|
|
438
|
+
artifact="package",
|
|
439
|
+
state=CoverageState.UNAVAILABLE,
|
|
440
|
+
detail="No baseline files supplied",
|
|
441
|
+
),
|
|
442
|
+
]
|
|
443
|
+
result.mapping_coverage = package.mapping_coverage
|
|
444
|
+
result.mapping_suggestions = package.suggestions
|
|
445
|
+
result.verified_crosschecks = package.mapping_coverage.verified
|
|
446
|
+
if disclosure := _single_formula_disclosure(workbook):
|
|
447
|
+
result.disclosures.append(disclosure)
|
|
448
|
+
findings = _apply_findings_budget(findings, result.coverage)
|
|
449
|
+
result.findings = triage(findings, profile)
|
|
450
|
+
return result
|
|
451
|
+
if mode is not QCRunMode.CYCLE_COMPARISON:
|
|
452
|
+
raise NotImplementedError(f"{mode.value} is not enabled yet")
|
|
453
|
+
if (baseline_excel is None) != (current_excel is None):
|
|
454
|
+
raise ValueError("Excel comparison needs both a baseline and a current workbook")
|
|
455
|
+
if (baseline_ppt is None) != (current_ppt is None):
|
|
456
|
+
raise ValueError("PPT comparison needs both a baseline and a current deck")
|
|
457
|
+
if baseline_excel is None and baseline_ppt is None:
|
|
458
|
+
raise ValueError("nothing to compare: supply an Excel pair and/or a PPT pair")
|
|
459
|
+
|
|
460
|
+
profile = profile or default_profile()
|
|
461
|
+
passwords = passwords or {}
|
|
462
|
+
result = QCRunResult(profile_name=profile.name, mode=mode)
|
|
463
|
+
findings: list[Finding] = []
|
|
464
|
+
current_workbook = None
|
|
465
|
+
dependency_graph: DependencyGraph | None = None
|
|
466
|
+
|
|
467
|
+
if baseline_excel is not None and current_excel is not None:
|
|
468
|
+
base_wb = _load_excel_file(
|
|
469
|
+
baseline_excel,
|
|
470
|
+
password=passwords.get(baseline_excel.name),
|
|
471
|
+
phase=RunPhase.LOADING_BASELINE_EXCEL,
|
|
472
|
+
allow_large_workbooks=allow_large_workbooks,
|
|
473
|
+
cancellation_token=cancellation_token,
|
|
474
|
+
on_progress=on_progress,
|
|
475
|
+
)
|
|
476
|
+
curr_wb = _load_excel_file(
|
|
477
|
+
current_excel,
|
|
478
|
+
password=passwords.get(current_excel.name),
|
|
479
|
+
phase=RunPhase.LOADING_CURRENT_EXCEL,
|
|
480
|
+
allow_large_workbooks=allow_large_workbooks,
|
|
481
|
+
cancellation_token=cancellation_token,
|
|
482
|
+
on_progress=on_progress,
|
|
483
|
+
)
|
|
484
|
+
current_workbook = curr_wb
|
|
485
|
+
result.files["baseline_excel"] = baseline_excel.name
|
|
486
|
+
result.files["current_excel"] = current_excel.name
|
|
487
|
+
result.coverage.append(_workload_coverage(base_wb, curr_wb))
|
|
488
|
+
if disclosure := _pair_formula_disclosure(base_wb, curr_wb):
|
|
489
|
+
result.disclosures.append(disclosure)
|
|
490
|
+
report_progress(on_progress, RunPhase.ANALYZING_EXCEL, total=1)
|
|
491
|
+
alignment = align_workbooks(
|
|
492
|
+
base_wb,
|
|
493
|
+
curr_wb,
|
|
494
|
+
profile,
|
|
495
|
+
cancellation_token=cancellation_token,
|
|
496
|
+
)
|
|
497
|
+
check_cancelled(cancellation_token)
|
|
498
|
+
alignment_detail = (
|
|
499
|
+
"Low-confidence key alignment skipped cell-level comparison for: "
|
|
500
|
+
+ ", ".join(alignment.low_confidence_regions)
|
|
501
|
+
if alignment.low_confidence_regions
|
|
502
|
+
else ""
|
|
503
|
+
)
|
|
504
|
+
value_start = len(findings)
|
|
505
|
+
findings += diff_workbook_values(
|
|
506
|
+
base_wb,
|
|
507
|
+
curr_wb,
|
|
508
|
+
alignment,
|
|
509
|
+
profile,
|
|
510
|
+
cancellation_token=cancellation_token,
|
|
511
|
+
)
|
|
512
|
+
check_cancelled(cancellation_token)
|
|
513
|
+
result.coverage.append(
|
|
514
|
+
CoverageItem(
|
|
515
|
+
check_id="excel-values",
|
|
516
|
+
label="Excel values and presentation",
|
|
517
|
+
artifact="excel",
|
|
518
|
+
state=(
|
|
519
|
+
CoverageState.DEGRADED
|
|
520
|
+
if alignment.low_confidence_regions
|
|
521
|
+
else CoverageState.CHECKED
|
|
522
|
+
),
|
|
523
|
+
findings=len(findings) - value_start,
|
|
524
|
+
detail=alignment_detail,
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
structure_start = len(findings)
|
|
528
|
+
structure_findings = diff_workbook_structure(
|
|
529
|
+
base_wb,
|
|
530
|
+
curr_wb,
|
|
531
|
+
alignment,
|
|
532
|
+
profile,
|
|
533
|
+
)
|
|
534
|
+
check_cancelled(cancellation_token)
|
|
535
|
+
findings += structure_findings
|
|
536
|
+
base_chart_state, base_chart_detail = chart_reference_coverage(base_wb)
|
|
537
|
+
curr_chart_state, curr_chart_detail = chart_reference_coverage(curr_wb)
|
|
538
|
+
structure_complete = (
|
|
539
|
+
base_wb.tables_available
|
|
540
|
+
and curr_wb.tables_available
|
|
541
|
+
and base_chart_state is CoverageState.CHECKED
|
|
542
|
+
and curr_chart_state is CoverageState.CHECKED
|
|
543
|
+
)
|
|
544
|
+
result.coverage.append(
|
|
545
|
+
CoverageItem(
|
|
546
|
+
check_id="excel-structure",
|
|
547
|
+
label="Excel workbook structure",
|
|
548
|
+
artifact="excel",
|
|
549
|
+
state=(
|
|
550
|
+
CoverageState.CHECKED
|
|
551
|
+
if structure_complete
|
|
552
|
+
else CoverageState.DEGRADED
|
|
553
|
+
),
|
|
554
|
+
findings=len(findings) - structure_start,
|
|
555
|
+
detail=(
|
|
556
|
+
""
|
|
557
|
+
if structure_complete
|
|
558
|
+
else "; ".join(
|
|
559
|
+
detail
|
|
560
|
+
for detail in (
|
|
561
|
+
(
|
|
562
|
+
"Excel table metadata is unavailable for one or both files"
|
|
563
|
+
if not base_wb.tables_available
|
|
564
|
+
or not curr_wb.tables_available
|
|
565
|
+
else ""
|
|
566
|
+
),
|
|
567
|
+
(
|
|
568
|
+
f"baseline charts: {base_chart_detail}"
|
|
569
|
+
if base_chart_state is not CoverageState.CHECKED
|
|
570
|
+
else ""
|
|
571
|
+
),
|
|
572
|
+
(
|
|
573
|
+
f"current charts: {curr_chart_detail}"
|
|
574
|
+
if curr_chart_state is not CoverageState.CHECKED
|
|
575
|
+
else ""
|
|
576
|
+
),
|
|
577
|
+
)
|
|
578
|
+
if detail
|
|
579
|
+
)
|
|
580
|
+
),
|
|
581
|
+
)
|
|
582
|
+
)
|
|
583
|
+
interaction_classes = {
|
|
584
|
+
FindingClass.DATA_VALIDATION_CHANGED,
|
|
585
|
+
FindingClass.CONDITIONAL_FORMAT_CHANGED,
|
|
586
|
+
}
|
|
587
|
+
interaction_findings = sum(
|
|
588
|
+
finding.finding_class in interaction_classes
|
|
589
|
+
for finding in structure_findings
|
|
590
|
+
)
|
|
591
|
+
baseline_rule_state, baseline_rule_detail = interaction_rule_coverage(base_wb)
|
|
592
|
+
current_rule_state, current_rule_detail = interaction_rule_coverage(curr_wb)
|
|
593
|
+
rule_state = _pair_coverage_state(
|
|
594
|
+
baseline_rule_state,
|
|
595
|
+
current_rule_state,
|
|
596
|
+
)
|
|
597
|
+
result.coverage.append(
|
|
598
|
+
CoverageItem(
|
|
599
|
+
check_id="excel-interaction-rules",
|
|
600
|
+
label="Data validation and conditional-format rules",
|
|
601
|
+
artifact="excel",
|
|
602
|
+
state=rule_state,
|
|
603
|
+
findings=interaction_findings,
|
|
604
|
+
detail=(
|
|
605
|
+
"Complete interaction-rule comparison"
|
|
606
|
+
if rule_state is CoverageState.CHECKED
|
|
607
|
+
else (
|
|
608
|
+
f"baseline: {baseline_rule_detail}; "
|
|
609
|
+
f"current: {current_rule_detail}"
|
|
610
|
+
)
|
|
611
|
+
),
|
|
612
|
+
)
|
|
613
|
+
)
|
|
614
|
+
baseline_style_state, baseline_style_detail = conditional_style_coverage(
|
|
615
|
+
base_wb
|
|
616
|
+
)
|
|
617
|
+
current_style_state, current_style_detail = conditional_style_coverage(
|
|
618
|
+
curr_wb
|
|
619
|
+
)
|
|
620
|
+
style_state = _pair_coverage_state(
|
|
621
|
+
baseline_style_state,
|
|
622
|
+
current_style_state,
|
|
623
|
+
)
|
|
624
|
+
result.coverage.append(
|
|
625
|
+
CoverageItem(
|
|
626
|
+
check_id="excel-conditional-format-styles",
|
|
627
|
+
label="Conditional-format differential styles",
|
|
628
|
+
artifact="excel",
|
|
629
|
+
state=style_state,
|
|
630
|
+
detail=(
|
|
631
|
+
"Stable differential styles compared"
|
|
632
|
+
if style_state is CoverageState.CHECKED
|
|
633
|
+
else (
|
|
634
|
+
f"baseline: {baseline_style_detail}; "
|
|
635
|
+
f"current: {current_style_detail}"
|
|
636
|
+
)
|
|
637
|
+
),
|
|
638
|
+
)
|
|
639
|
+
)
|
|
640
|
+
result.coverage.append(
|
|
641
|
+
availability_coverage(
|
|
642
|
+
artifact="excel",
|
|
643
|
+
rule_count=sum(
|
|
644
|
+
len(sheet.availability_rules)
|
|
645
|
+
for sheet_name, sheet in profile.excel.sheets.items()
|
|
646
|
+
if sheet_name not in profile.excel.ignore_sheets
|
|
647
|
+
and not sheet.ignore
|
|
648
|
+
),
|
|
649
|
+
issues=excel_availability_issues(curr_wb, profile),
|
|
650
|
+
)
|
|
651
|
+
)
|
|
652
|
+
formula_start = len(findings)
|
|
653
|
+
findings += diff_workbook_formulas(
|
|
654
|
+
base_wb,
|
|
655
|
+
curr_wb,
|
|
656
|
+
alignment,
|
|
657
|
+
profile,
|
|
658
|
+
cancellation_token=cancellation_token,
|
|
659
|
+
)
|
|
660
|
+
check_cancelled(cancellation_token)
|
|
661
|
+
formula_text_checked = formula_text_compatible(base_wb, curr_wb)
|
|
662
|
+
formula_complete = (
|
|
663
|
+
formula_text_checked and not alignment.low_confidence_regions
|
|
664
|
+
)
|
|
665
|
+
result.coverage.append(
|
|
666
|
+
CoverageItem(
|
|
667
|
+
check_id="excel-formulas",
|
|
668
|
+
label="Excel formulas",
|
|
669
|
+
artifact="excel",
|
|
670
|
+
state=(
|
|
671
|
+
CoverageState.CHECKED
|
|
672
|
+
if formula_complete
|
|
673
|
+
else CoverageState.DEGRADED
|
|
674
|
+
),
|
|
675
|
+
findings=len(findings) - formula_start,
|
|
676
|
+
detail=(
|
|
677
|
+
f"Compatible formula text source: {base_wb.formula_source}"
|
|
678
|
+
if formula_complete
|
|
679
|
+
else (
|
|
680
|
+
alignment_detail
|
|
681
|
+
if alignment.low_confidence_regions
|
|
682
|
+
else (
|
|
683
|
+
"Formula presence checks completed; semantic formula text "
|
|
684
|
+
"checks unavailable"
|
|
685
|
+
)
|
|
686
|
+
)
|
|
687
|
+
),
|
|
688
|
+
)
|
|
689
|
+
)
|
|
690
|
+
if curr_wb.formulas_available:
|
|
691
|
+
dependency_graph = build_dependency_graph(
|
|
692
|
+
curr_wb,
|
|
693
|
+
cancellation_token=cancellation_token,
|
|
694
|
+
)
|
|
695
|
+
annotate_impacts(findings, dependency_graph)
|
|
696
|
+
dependency_state = dependency_graph.coverage_state
|
|
697
|
+
dependency_detail = dependency_graph.coverage_detail
|
|
698
|
+
else:
|
|
699
|
+
dependency_state = CoverageState.UNAVAILABLE
|
|
700
|
+
dependency_detail = "Formula text is unavailable for dependency extraction"
|
|
701
|
+
result.coverage.append(
|
|
702
|
+
CoverageItem(
|
|
703
|
+
check_id="excel-dependencies",
|
|
704
|
+
label="Formula dependency impact tracing",
|
|
705
|
+
artifact="excel",
|
|
706
|
+
state=dependency_state,
|
|
707
|
+
detail=dependency_detail,
|
|
708
|
+
)
|
|
709
|
+
)
|
|
710
|
+
ignored_sheets = set(profile.excel.ignore_sheets)
|
|
711
|
+
ignored_sheets.update(
|
|
712
|
+
sheet_name
|
|
713
|
+
for sheet_name, sheet_profile in profile.excel.sheets.items()
|
|
714
|
+
if sheet_profile.ignore
|
|
715
|
+
)
|
|
716
|
+
controls = evaluate_controls(
|
|
717
|
+
curr_wb,
|
|
718
|
+
profile.excel.controls,
|
|
719
|
+
ignored_sheets=ignored_sheets,
|
|
720
|
+
)
|
|
721
|
+
findings += controls.findings
|
|
722
|
+
if controls.coverage is not None:
|
|
723
|
+
result.coverage.append(controls.coverage)
|
|
724
|
+
annotate_chart_impacts(findings, curr_wb, dependency_graph)
|
|
725
|
+
limit_impacts(findings)
|
|
726
|
+
attach_excerpts(findings, base_wb, curr_wb)
|
|
727
|
+
check_cancelled(cancellation_token)
|
|
728
|
+
report_progress(
|
|
729
|
+
on_progress,
|
|
730
|
+
RunPhase.ANALYZING_EXCEL,
|
|
731
|
+
processed=1,
|
|
732
|
+
total=1,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
current_deck = None
|
|
736
|
+
if baseline_ppt is not None and current_ppt is not None:
|
|
737
|
+
base_deck = _load_powerpoint_file(
|
|
738
|
+
baseline_ppt,
|
|
739
|
+
password=passwords.get(baseline_ppt.name),
|
|
740
|
+
phase=RunPhase.LOADING_BASELINE_POWERPOINT,
|
|
741
|
+
cancellation_token=cancellation_token,
|
|
742
|
+
on_progress=on_progress,
|
|
743
|
+
)
|
|
744
|
+
current_deck = _load_powerpoint_file(
|
|
745
|
+
current_ppt,
|
|
746
|
+
password=passwords.get(current_ppt.name),
|
|
747
|
+
phase=RunPhase.LOADING_CURRENT_POWERPOINT,
|
|
748
|
+
cancellation_token=cancellation_token,
|
|
749
|
+
on_progress=on_progress,
|
|
750
|
+
)
|
|
751
|
+
result.files["baseline_ppt"] = baseline_ppt.name
|
|
752
|
+
result.files["current_ppt"] = current_ppt.name
|
|
753
|
+
report_progress(on_progress, RunPhase.ANALYZING_POWERPOINT, total=1)
|
|
754
|
+
matching = match_slides(base_deck, current_deck, profile.ppt)
|
|
755
|
+
check_cancelled(cancellation_token)
|
|
756
|
+
ppt_start = len(findings)
|
|
757
|
+
findings += diff_decks(matching, profile.ppt)
|
|
758
|
+
check_cancelled(cancellation_token)
|
|
759
|
+
result.coverage.append(
|
|
760
|
+
CoverageItem(
|
|
761
|
+
check_id="ppt-comparison",
|
|
762
|
+
label="PowerPoint content and charts",
|
|
763
|
+
artifact="ppt",
|
|
764
|
+
state=(
|
|
765
|
+
CoverageState.CHECKED
|
|
766
|
+
if base_deck.charts_available and current_deck.charts_available
|
|
767
|
+
else CoverageState.DEGRADED
|
|
768
|
+
),
|
|
769
|
+
findings=len(findings) - ppt_start,
|
|
770
|
+
detail=(
|
|
771
|
+
"Complete native chart extraction unavailable for one or both decks"
|
|
772
|
+
if not base_deck.charts_available
|
|
773
|
+
or not current_deck.charts_available
|
|
774
|
+
else "Complete semantic slide-element comparison"
|
|
775
|
+
),
|
|
776
|
+
)
|
|
777
|
+
)
|
|
778
|
+
report_progress(
|
|
779
|
+
on_progress,
|
|
780
|
+
RunPhase.ANALYZING_POWERPOINT,
|
|
781
|
+
processed=1,
|
|
782
|
+
total=1,
|
|
783
|
+
)
|
|
784
|
+
result.coverage.append(
|
|
785
|
+
availability_coverage(
|
|
786
|
+
artifact="ppt",
|
|
787
|
+
rule_count=len(profile.ppt.availability_rules),
|
|
788
|
+
issues=ppt_availability_issues(current_deck, profile.ppt),
|
|
789
|
+
)
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
if (
|
|
793
|
+
current_deck is not None
|
|
794
|
+
and dependency_graph is not None
|
|
795
|
+
and profile.crosscheck.mappings
|
|
796
|
+
):
|
|
797
|
+
annotate_ppt_chart_impacts(
|
|
798
|
+
findings,
|
|
799
|
+
current_deck,
|
|
800
|
+
profile.crosscheck,
|
|
801
|
+
dependency_graph,
|
|
802
|
+
)
|
|
803
|
+
limit_impacts(findings)
|
|
804
|
+
|
|
805
|
+
if baseline_excel is None:
|
|
806
|
+
result.coverage.extend(
|
|
807
|
+
[
|
|
808
|
+
CoverageItem(
|
|
809
|
+
check_id="excel-workload",
|
|
810
|
+
label="Excel workload safeguards",
|
|
811
|
+
artifact="excel",
|
|
812
|
+
state=CoverageState.UNAVAILABLE,
|
|
813
|
+
detail="Excel pair not supplied",
|
|
814
|
+
),
|
|
815
|
+
CoverageItem(
|
|
816
|
+
check_id="excel-values",
|
|
817
|
+
label="Excel values and presentation",
|
|
818
|
+
artifact="excel",
|
|
819
|
+
state=CoverageState.UNAVAILABLE,
|
|
820
|
+
detail="Excel pair not supplied",
|
|
821
|
+
),
|
|
822
|
+
CoverageItem(
|
|
823
|
+
check_id="excel-structure",
|
|
824
|
+
label="Excel workbook structure",
|
|
825
|
+
artifact="excel",
|
|
826
|
+
state=CoverageState.UNAVAILABLE,
|
|
827
|
+
detail="Excel pair not supplied",
|
|
828
|
+
),
|
|
829
|
+
CoverageItem(
|
|
830
|
+
check_id="excel-formulas",
|
|
831
|
+
label="Excel formulas",
|
|
832
|
+
artifact="excel",
|
|
833
|
+
state=CoverageState.UNAVAILABLE,
|
|
834
|
+
detail="Excel pair not supplied",
|
|
835
|
+
),
|
|
836
|
+
CoverageItem(
|
|
837
|
+
check_id="excel-dependencies",
|
|
838
|
+
label="Formula dependency impact tracing",
|
|
839
|
+
artifact="excel",
|
|
840
|
+
state=CoverageState.UNAVAILABLE,
|
|
841
|
+
detail="Excel pair not supplied",
|
|
842
|
+
),
|
|
843
|
+
CoverageItem(
|
|
844
|
+
check_id="excel-interaction-rules",
|
|
845
|
+
label="Data validation and conditional-format rules",
|
|
846
|
+
artifact="excel",
|
|
847
|
+
state=CoverageState.UNAVAILABLE,
|
|
848
|
+
detail="Excel pair not supplied",
|
|
849
|
+
),
|
|
850
|
+
CoverageItem(
|
|
851
|
+
check_id="excel-conditional-format-styles",
|
|
852
|
+
label="Conditional-format differential styles",
|
|
853
|
+
artifact="excel",
|
|
854
|
+
state=CoverageState.UNAVAILABLE,
|
|
855
|
+
detail="Excel pair not supplied",
|
|
856
|
+
),
|
|
857
|
+
CoverageItem(
|
|
858
|
+
check_id="excel-availability",
|
|
859
|
+
label="Availability boundaries",
|
|
860
|
+
artifact="excel",
|
|
861
|
+
state=CoverageState.UNAVAILABLE,
|
|
862
|
+
detail="Excel pair not supplied",
|
|
863
|
+
),
|
|
864
|
+
]
|
|
865
|
+
)
|
|
866
|
+
if baseline_ppt is None:
|
|
867
|
+
result.coverage.extend(
|
|
868
|
+
[
|
|
869
|
+
CoverageItem(
|
|
870
|
+
check_id="ppt-comparison",
|
|
871
|
+
label="PowerPoint content and charts",
|
|
872
|
+
artifact="ppt",
|
|
873
|
+
state=CoverageState.UNAVAILABLE,
|
|
874
|
+
detail="PowerPoint pair not supplied",
|
|
875
|
+
),
|
|
876
|
+
CoverageItem(
|
|
877
|
+
check_id="ppt-availability",
|
|
878
|
+
label="Availability boundaries",
|
|
879
|
+
artifact="ppt",
|
|
880
|
+
state=CoverageState.UNAVAILABLE,
|
|
881
|
+
detail="PowerPoint pair not supplied",
|
|
882
|
+
),
|
|
883
|
+
]
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
if current_deck is not None and current_workbook is not None and profile.crosscheck.mappings:
|
|
887
|
+
report_progress(on_progress, RunPhase.CROSSCHECKING, total=1)
|
|
888
|
+
crosscheck_start = len(findings)
|
|
889
|
+
crosscheck = verify_mappings(current_deck, current_workbook, profile.crosscheck)
|
|
890
|
+
check_cancelled(cancellation_token)
|
|
891
|
+
findings += crosscheck.findings
|
|
892
|
+
result.verified_crosschecks = len(crosscheck.verified)
|
|
893
|
+
result.coverage.append(
|
|
894
|
+
CoverageItem(
|
|
895
|
+
check_id="excel-ppt-crosscheck",
|
|
896
|
+
label="Excel to PowerPoint mappings",
|
|
897
|
+
artifact="package",
|
|
898
|
+
state=(
|
|
899
|
+
CoverageState.CHECKED
|
|
900
|
+
if current_deck.charts_available
|
|
901
|
+
else CoverageState.DEGRADED
|
|
902
|
+
),
|
|
903
|
+
findings=len(findings) - crosscheck_start,
|
|
904
|
+
detail=(
|
|
905
|
+
f"{len(crosscheck.verified)} mappings verified"
|
|
906
|
+
+ (
|
|
907
|
+
""
|
|
908
|
+
if current_deck.charts_available
|
|
909
|
+
else "; visible native chart labels unavailable"
|
|
910
|
+
)
|
|
911
|
+
),
|
|
912
|
+
)
|
|
913
|
+
)
|
|
914
|
+
report_progress(
|
|
915
|
+
on_progress,
|
|
916
|
+
RunPhase.CROSSCHECKING,
|
|
917
|
+
processed=1,
|
|
918
|
+
total=1,
|
|
919
|
+
)
|
|
920
|
+
else:
|
|
921
|
+
reason = (
|
|
922
|
+
"Both current Excel and PowerPoint are required"
|
|
923
|
+
if current_deck is None or current_workbook is None
|
|
924
|
+
else "No confirmed mappings in the selected profile"
|
|
925
|
+
)
|
|
926
|
+
result.coverage.append(
|
|
927
|
+
CoverageItem(
|
|
928
|
+
check_id="excel-ppt-crosscheck",
|
|
929
|
+
label="Excel to PowerPoint mappings",
|
|
930
|
+
artifact="package",
|
|
931
|
+
state=CoverageState.UNAVAILABLE,
|
|
932
|
+
detail=reason,
|
|
933
|
+
)
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
findings = _apply_findings_budget(findings, result.coverage)
|
|
937
|
+
result.findings = triage(findings, profile)
|
|
938
|
+
logger.info(
|
|
939
|
+
"QC run complete: %s findings (%s)",
|
|
940
|
+
len(result.findings),
|
|
941
|
+
", ".join(f"{sev.value}={count}" for sev, count in result.counts.items()),
|
|
942
|
+
)
|
|
943
|
+
return result
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
@dataclass(slots=True)
|
|
947
|
+
class FindingsDelta:
|
|
948
|
+
"""How a re-QC run compares to its predecessor (non-expected findings)."""
|
|
949
|
+
|
|
950
|
+
resolved: int
|
|
951
|
+
new: int
|
|
952
|
+
persisting: int
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _identity_key(finding: Finding) -> tuple[str, ...]:
|
|
956
|
+
return (
|
|
957
|
+
finding.artifact,
|
|
958
|
+
finding.finding_class.value,
|
|
959
|
+
finding.sheet or "",
|
|
960
|
+
finding.slide or "",
|
|
961
|
+
finding.location or finding.baseline_location or "",
|
|
962
|
+
finding.element or "",
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def compare_findings(previous: list[Finding], current: list[Finding]) -> FindingsDelta:
|
|
967
|
+
"""Match findings by identity (not by id) to compute a fix-progress delta."""
|
|
968
|
+
previous_keys = {
|
|
969
|
+
_identity_key(f) for f in previous if f.severity is not Severity.EXPECTED
|
|
970
|
+
}
|
|
971
|
+
current_keys = {
|
|
972
|
+
_identity_key(f) for f in current if f.severity is not Severity.EXPECTED
|
|
973
|
+
}
|
|
974
|
+
return FindingsDelta(
|
|
975
|
+
resolved=len(previous_keys - current_keys),
|
|
976
|
+
new=len(current_keys - previous_keys),
|
|
977
|
+
persisting=len(previous_keys & current_keys),
|
|
978
|
+
)
|