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.
Files changed (78) hide show
  1. cadence_diff-0.1.0.dist-info/METADATA +245 -0
  2. cadence_diff-0.1.0.dist-info/RECORD +78 -0
  3. cadence_diff-0.1.0.dist-info/WHEEL +4 -0
  4. cadence_diff-0.1.0.dist-info/entry_points.txt +3 -0
  5. cadence_diff-0.1.0.dist-info/licenses/LICENSE +190 -0
  6. qc_tool/__init__.py +3 -0
  7. qc_tool/__main__.py +6 -0
  8. qc_tool/attestation.py +209 -0
  9. qc_tool/availability.py +273 -0
  10. qc_tool/cli.py +769 -0
  11. qc_tool/config/__init__.py +1 -0
  12. qc_tool/config/lint.py +480 -0
  13. qc_tool/config/profile.py +207 -0
  14. qc_tool/coverage.py +35 -0
  15. qc_tool/crosscheck/__init__.py +1 -0
  16. qc_tool/crosscheck/numbers.py +146 -0
  17. qc_tool/crosscheck/package.py +154 -0
  18. qc_tool/crosscheck/trace.py +374 -0
  19. qc_tool/engine.py +978 -0
  20. qc_tool/excel/__init__.py +1 -0
  21. qc_tool/excel/align.py +452 -0
  22. qc_tool/excel/charts.py +931 -0
  23. qc_tool/excel/context.py +106 -0
  24. qc_tool/excel/controls.py +266 -0
  25. qc_tool/excel/dependency.py +252 -0
  26. qc_tool/excel/diff_structure.py +326 -0
  27. qc_tool/excel/diff_values.py +327 -0
  28. qc_tool/excel/formulas.py +516 -0
  29. qc_tool/excel/interaction.py +548 -0
  30. qc_tool/excel/periods.py +117 -0
  31. qc_tool/excel/preflight.py +615 -0
  32. qc_tool/excel/references.py +436 -0
  33. qc_tool/excel/regions.py +201 -0
  34. qc_tool/findings.py +220 -0
  35. qc_tool/fingerprint.py +369 -0
  36. qc_tool/fingerprint.schema.json +15 -0
  37. qc_tool/history/__init__.py +1 -0
  38. qc_tool/history/store.py +287 -0
  39. qc_tool/io/__init__.py +1 -0
  40. qc_tool/io/decrypt.py +64 -0
  41. qc_tool/io/excel_formula.py +256 -0
  42. qc_tool/io/excel_formula_worker.py +280 -0
  43. qc_tool/io/formula_enrichment.py +95 -0
  44. qc_tool/io/libreoffice_formula.py +232 -0
  45. qc_tool/io/loader.py +973 -0
  46. qc_tool/io/model.py +356 -0
  47. qc_tool/io/ooxml_chart.py +530 -0
  48. qc_tool/io/ooxml_interaction.py +400 -0
  49. qc_tool/io/ooxml_worksheet.py +405 -0
  50. qc_tool/io/xlsb_formula.py +313 -0
  51. qc_tool/package-sanitize.schema.json +31 -0
  52. qc_tool/package_sanitize.py +255 -0
  53. qc_tool/ppt/__init__.py +1 -0
  54. qc_tool/ppt/chart_xml.py +422 -0
  55. qc_tool/ppt/diff.py +120 -0
  56. qc_tool/ppt/element_diff.py +878 -0
  57. qc_tool/ppt/element_match.py +317 -0
  58. qc_tool/ppt/extract.py +212 -0
  59. qc_tool/ppt/match.py +137 -0
  60. qc_tool/ppt/model.py +132 -0
  61. qc_tool/ppt/preflight.py +307 -0
  62. qc_tool/privacy.py +392 -0
  63. qc_tool/progress.py +78 -0
  64. qc_tool/report/__init__.py +1 -0
  65. qc_tool/report/excel_report.py +205 -0
  66. qc_tool/report/findings.schema.json +32 -0
  67. qc_tool/report/html_report.py +38 -0
  68. qc_tool/report/json_report.py +55 -0
  69. qc_tool/report/templates/report.html.j2 +117 -0
  70. qc_tool/sanitize.py +598 -0
  71. qc_tool/security.py +34 -0
  72. qc_tool/server_config.py +86 -0
  73. qc_tool/triage/__init__.py +1 -0
  74. qc_tool/triage/rules.py +211 -0
  75. qc_tool/ui/__init__.py +1 -0
  76. qc_tool/ui/app.py +1261 -0
  77. qc_tool/ui/guide.py +452 -0
  78. 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
+ )