Setting the file. One moment.
Validate Heroku Migration Report · Heroku To AWS · aws/agent-toolkit-for-aws · Skills Docs
ContentsBack to the top of the page 28.3
Clarify
81
Creating Amazon Aurora Db Cluster With Instances
104
Routing Traffic With Route53 And CloudFront
Resilience Program Design
Creating API Gateway Stage
def _section_counts
— line 168
This file
Number 28.50
Position 50 of 58
Type Python
Size 49 KB
Lines 1,113 scripts/ validate-heroku-migration-report.py
Python · 1,113 lines · 49 KB
15
- <html> must declare a lang attribute; any <th> must declare scope=col|row;
16 any <figure> must carry aria-label + <figcaption>.
17
18 Deliberately NOT enforced (would false-fail the intentionally-thin Heroku
19 report, whose skeleton puts <nav class="toc"> before the verdict and emits no
20 <h1> / table <caption>): decision-before-TOC ordering, single-<h1>, per-table
21 <caption>, glossary/appendix set.
22
23 Exit 0 on PASS, 1 on FAIL.
24
25 Usage:
26 python3 validate-heroku-migration-report.py /path/to/migration-report.html \\
27 --migration-dir "$MIGRATION_DIR"
28 """
29
30 from __future__ import annotations
31
32 import argparse
33 import json
34 import re
35 import sys
36 from html.parser import HTMLParser
37 from pathlib import Path
38
39 # Sections required in BOTH report modes. cost-optimization is required in full
40 # mode only (see _required_sections) — the decision pack is pre-execution and does
41 # not carry the optimization table.
42 COMMON_REQUIRED_SECTION_IDS = [
43 "decision-summary" ,
44 "exec-costs" ,
45 ]
46 # The structural difference between modes: full (migration-report.html) ends on a
47 # next-steps list AND carries cost-optimization; decision (decision-report.html) ends
48 # on a decision-cta pointing at Generate (next-steps assumes MIGRATION_GUIDE.md /
49 # terraform/ already exist — they don't yet in decision mode).
50 MODE_REQUIRED_SECTION_ID = {
51 "full" : "next-steps" ,
52 "decision" : "decision-cta" ,
53 }
54
55
56 def _required_sections (mode: str ) -> list[ str ]:
57 required = [ * COMMON_REQUIRED_SECTION_IDS , MODE_REQUIRED_SECTION_ID [mode]]
58 if mode == "full" :
59 required.append( "cost-optimization" )
60 return required
61
62 # Section identity/count/fragment come from the stdlib HTML parser, never a
63 # raw-source regex, so that:
64 # - a <section id="..."> that exists ONLY inside an HTML comment (e.g. the
65 # skeleton's commented `<!-- <section id="what-if-scenarios"> -->` placeholder)
66 # is never counted — HTMLParser routes comment text to handle_comment and
67 # never re-tokenizes it as a tag;
68 # - a section wrapped in an inert subtree (<template>/<script>/<style>) is not
69 # counted — it is never rendered, so it must not satisfy a required-section
70 # gate, and its inner HTML is not returned as a section fragment;
71 # - the real `id` attribute is read regardless of spelling (quoted, unquoted,
72 # or spaced `id = "x"`), and a `data-id` (or any non-`id` attribute) is NOT
73 # mistaken for the section id;
74 # - a duplicate that exists only in a comment does not inflate the count.
75 _SECTION_INERT_TAGS = { "script" , "style" , "template" }
76
77
78 class _SectionParser ( HTMLParser ):
79 """Parse rendered <section> structure: per-id counts and inner-HTML fragments,
80 excluding comments and inert (script/style/template) subtrees."""
81
82 def __init__ (self) -> None :
83 super (). __init__ ( convert_charrefs = False ) # keep raw text/entities in fragments
84 self .counts: dict[ str , int ] = {}
85 # Captured fragments: id -> list of inner-HTML strings (document order).
86 self .fragments: dict[ str , list[ str ]] = {}
87 self ._inert_depth = 0
88 # Stack of open sections we are capturing: each {id, parts, seen_depth}.
89 self ._open_sections: list[ dict ] = []
90 self ._section_depth = 0 # nesting depth of <section> (for fragment close)
91
92 def _emit (self, markup: str ) -> None :
93 # Append raw markup to every section fragment currently being captured.
94 for s in self ._open_sections:
95 s[ "parts" ].append(markup)
96
97 def handle_starttag (self, tag: str , attrs: list[tuple[ str , str | None ]]) -> None :
98 if tag in _SECTION_INERT_TAGS :
99 self ._inert_depth += 1
100 return
101 if self ._inert_depth > 0 :
102 return # inside an inert subtree — not rendered
103 if tag == "section" :
104 # Keep nested section attributes in the containing fragment so a
105 # second parser retains cost anchors and hidden ancestry.
106 self ._emit( self .get_starttag_text() or "" )
107 sid = dict (attrs).get( "id" )
108 self ._section_depth += 1
109 if sid:
110 self .counts[sid] = self .counts.get(sid, 0 ) + 1
111 self .fragments.setdefault(sid, [])
112 self ._open_sections.append(
113 { "id" : sid, "parts" : [], "depth" : self ._section_depth}
114 )
115 return
116 self ._emit( self .get_starttag_text() or "" )
117
118 def handle_startendtag (self, tag: str , attrs: list[tuple[ str , str | None ]]) -> None :
119 if tag in _SECTION_INERT_TAGS or self ._inert_depth > 0 :
120 return
121 if tag == "section" :
122 self ._emit( self .get_starttag_text() or "" )
123 sid = dict (attrs).get( "id" )
124 if sid:
125 self .counts[sid] = self .counts.get(sid, 0 ) + 1
126 self .fragments.setdefault(sid, []).append( "" )
127 return
128 self ._emit( self .get_starttag_text() or "" )
129
130 def handle_endtag (self, tag: str ) -> None :
131 if tag in _SECTION_INERT_TAGS :
132 if self ._inert_depth > 0 :
133 self ._inert_depth -= 1
134 return
135 if self ._inert_depth > 0 :
136 return
137 if tag == "section" :
138 # Close the innermost open captured section at this depth.
139 while self ._open_sections and self ._open_sections[ - 1 ][ "depth" ] >= self ._section_depth:
140 done = self ._open_sections.pop()
141 self .fragments.setdefault(done[ "id" ], []).append( "" .join(done[ "parts" ]))
142 if self ._section_depth > 0 :
143 self ._section_depth -= 1
144 self ._emit( "</section>" )
145 return
146 self ._emit( f "</ { tag } >" )
147
148 def handle_data (self, data: str ) -> None :
149 if self ._inert_depth == 0 :
150 self ._emit(data)
151
152 def handle_entityref (self, name: str ) -> None :
153 if self ._inert_depth == 0 :
154 self ._emit( f "& { name } ;" )
155
156 def handle_charref (self, name: str ) -> None :
157 if self ._inert_depth == 0 :
158 self ._emit( f "&# { name } ;" )
159
160
161 def _parse_sections (html: str ) -> _SectionParser:
162 parser = _SectionParser()
163 parser.feed(html)
164 parser.close()
165 return parser
166
167
168 def _section_counts (html: str ) -> dict[ str , int ]:
169 """Count rendered <section id> occurrences (comments/inert excluded)."""
170 return _parse_sections(html).counts
171
172
173 def _section_html (html: str , section_id: str ) -> str | None :
174 """Return the inner HTML of the first rendered <section id="section_id">,
175 or None when it is absent (or exists only in a comment/inert subtree). A
176 nested <section> does not truncate early — the parser closes on the
177 matching depth."""
178 frags = _parse_sections(html).fragments.get(section_id)
179 if not frags:
180 return None
181 return frags[ 0 ]
182
183
184 def _normalize_money (text: str ) -> str | None :
185 """Reduce a rendered money string to its canonical display form for exact
186 comparison against the JSON figure (same normalization on both sides via
187 `_canonical_money`). '$1,415/mo' -> '1415'; '$112.90' -> '113'; '$0.40' ->
188 '0.40'. Returns None when no dollar amount is present. Cents are NO LONGER
189 truncated — a correctly rounded report figure must match, not be rejected."""
190 m = re.search( r " \$ \s * ([ 0-9 ][ 0-9, ] * (?: \. [ 0-9 ] + ) ? ) " , text)
191 if not m:
192 return None
193 try :
194 return _canonical_money( float (m.group( 1 ).replace( "," , "" )))
195 except ( TypeError , ValueError ):
196 return None
197
198
199 def _canonical_money (value: float ) -> str :
200 """Canonical display form for a dollar amount, matching the emitter's own
201 rule (generate-report.md currency rule / the currency-formatting gate):
202 monthly-scale totals (>= $2 after rounding) round to the nearest whole
203 dollar; genuinely small totals keep two-decimal cents. Both the rendered
204 figure and the JSON figure pass through this SAME function before comparison,
205 so a correctly rounded `$113` for `112.90` matches (not truncated to `112`),
206 and the small-total exception (e.g. `$0.40`) is preserved instead of being
207 truncated to `0`. Decides precision on the ROUNDED magnitude so a value that
208 rounds up across the $2 threshold (e.g. 1.999) canonicalizes to `"2"`,
209 matching a displayed `$2`, instead of `"2.00"`."""
210 try :
211 v = float (value)
212 except ( TypeError , ValueError ):
213 raise
214 rounded_whole = int ( round (v))
215 if abs (rounded_whole) >= _CENTS_MEANINGFUL_BELOW :
216 return str (rounded_whole) # nearest-dollar; 112.90->113, 112.4->112, 1.999->2
217 return f " { v :.2f} " # genuinely small total: retain cents (0.40 -> "0.40")
218
219
220 # Elements whose subtree the browser never renders — an anchor (or its text)
221 # inside one must never stand in for the visible figure. Mirrors the currency
222 # text parser's inert set so the anchor collector and the currency parser agree
223 # on what "rendered" means.
224 _ANCHOR_INERT_TAGS = { "script" , "style" , "template" }
225
226
227 # data-cost-key anchor -> estimation-infra.json path. Heroku asserts the recommended
228 # AWS monthly (Balanced) figure only; the current-spend comparator is a follow-up
229 # (Heroku's current_costs key is not yet settled — heroku_monthly_baseline vs _estimated).
230 _COST_ANCHORS = {
231 "aws_monthly_balanced" : ( "projected_costs" , "aws_monthly_balanced" ),
232 }
233 # Load-bearing key: when its JSON value exists AND exec-costs is present, the anchor
234 # MUST be present (a missing anchor is a FAIL, not a skip — otherwise an un-anchored
235 # wrong figure passes, the bug P1-C exists to catch).
236 _REQUIRED_COST_KEYS = ( "aws_monthly_balanced" ,)
237
238
239 class _CostAnchorParser ( HTMLParser ):
240 """Collect the rendered text of every `data-cost-key="..."` element.
241
242 Ported from validate-migration-report.py's parser (GCP) so both providers
243 agree on what "rendered" means. Uses the stdlib HTML parser rather than a
244 regex so that:
245 (a) markup inside an HTML comment is never mistaken for a real anchor —
246 comments are a distinct token the parser never re-tokenizes as tags;
247 (b) nested child markup is read through the anchored element's OWN matching
248 close tag, not the first `</` encountered, by counting nested
249 opens/closes — and a NESTED `data-cost-key` element is collected as its
250 own anchor too (an outer anchor being open must not swallow a recognized
251 inner figure), tracked on a stack;
252 (c) inert subtrees (`<script>`, `<style>`, `<template>`) are skipped — their
253 content is never rendered by the browser, so an anchor or dollar token
254 placed there must not satisfy the visible-figure requirement, even when
255 the inert element itself carries `data-cost-key`;
256 (d) an element with a `hidden` attribute is skipped for the same reason —
257 its subtree is not rendered.
258 Character references are decoded automatically (`convert_charrefs=True`).
259 """
260
261 # Void elements never have an end tag, so they must not be pushed onto the
262 # element stack (doing so would desync every subsequent close).
263 _VOID_TAGS = {
264 "area" , "base" , "br" , "col" , "embed" , "hr" , "img" , "input" ,
265 "link" , "meta" , "param" , "source" , "track" , "wbr" ,
266 }
267
268 def __init__ (self) -> None :
269 super (). __init__ ( convert_charrefs = True )
270 self .results: list[tuple[ str , str ]] = [] # (key, inner text), document order
271 # One frame per open non-void element, innermost last. Each frame:
272 # {"tag", "inert": bool, "hidden": bool, "anchor": {key,parts}|None}
273 # `inert`/`hidden` are STICKY down the subtree (an element inside an inert
274 # or hidden ancestor is itself skipped) — computed as ancestor-or-self.
275 self ._stack: list[ dict ] = []
276
277 def _in_skip (self) -> bool :
278 """True when the current point is inside an inert or hidden subtree."""
279 return bool ( self ._stack) and ( self ._stack[ - 1 ][ "inert" ] or self ._stack[ - 1 ][ "hidden" ])
280
281 @ staticmethod
282 def _is_hidden (attrs: list[tuple[ str , str | None ]]) -> bool :
283 # `hidden` is a BOOLEAN attribute: its mere presence hides the subtree,
284 # regardless of value. In HTML `hidden="false"` is NOT a not-hidden value —
285 # "false" is an invalid value for a boolean attribute, whose invalid-value
286 # default is the Hidden state. So any `hidden` attribute (including
287 # `hidden=""`, `hidden="hidden"`, and `hidden="false"`) hides the element;
288 # only the attribute's ABSENCE leaves it visible.
289 return "hidden" in dict (attrs)
290
291 def handle_starttag (self, tag: str , attrs: list[tuple[ str , str | None ]]) -> None :
292 parent = self ._stack[ - 1 ] if self ._stack else None
293 # inert/hidden are STICKY: an element inside an inert/hidden ancestor is
294 # itself inert/hidden. Kept as clean booleans (never a truthy list).
295 inert = bool (parent and parent[ "inert" ]) or tag in _ANCHOR_INERT_TAGS
296 hidden = bool (parent and parent[ "hidden" ]) or self ._is_hidden(attrs)
297 anchor = None
298 key = dict (attrs).get( "data-cost-key" )
299 # Start a new anchor only when this element is actually rendered.
300 if key and not inert and not hidden:
301 anchor = { "key" : key.lower(), "parts" : []}
302 frame = { "tag" : tag, "inert" : inert, "hidden" : hidden, "anchor" : anchor}
303 if tag not in self . _VOID_TAGS :
304 self ._stack.append(frame)
305
306 def handle_startendtag (self, tag: str , attrs: list[tuple[ str , str | None ]]) -> None :
307 if tag in _ANCHOR_INERT_TAGS or self ._in_skip():
308 return
309 parent_hidden = self ._stack[ - 1 ][ "hidden" ] if self ._stack else False
310 key = dict (attrs).get( "data-cost-key" )
311 # A self-closed anchor has no text content; record it (empty) only if rendered.
312 if key and not parent_hidden and not self ._is_hidden(attrs):
313 self .results.append((key.lower(), "" ))
314
315 def handle_endtag (self, tag: str ) -> None :
316 if tag in self . _VOID_TAGS :
317 return
318 # Pop to the nearest matching open tag (tolerate minor misnesting). Every
319 # frame in the popped slice that carried an anchor is emitted — including
320 # any INNER anchors implicitly closed by an outer element's end tag — so a
321 # nested `data-cost-key` is never silently dropped. Emit innermost-first,
322 # then the matched frame, all in the order they closed.
323 for i in range ( len ( self ._stack) - 1 , - 1 , - 1 ):
324 if self ._stack[i][ "tag" ] == tag:
325 popped = self ._stack[i:]
326 del self ._stack[i:]
327 for frame in reversed (popped): # innermost closes first
328 if frame[ "anchor" ] is not None :
329 self .results.append(
330 (frame[ "anchor" ][ "key" ], "" .join(frame[ "anchor" ][ "parts" ]))
331 )
332 return
333 # Unmatched close tag: ignore.
334
335 def handle_data (self, data: str ) -> None :
336 if self ._in_skip():
337 return
338 # Append rendered text to every open anchor on the stack (an outer
339 # anchor's text legitimately includes its children's text).
340 for frame in self ._stack:
341 if frame[ "anchor" ] is not None :
342 frame[ "anchor" ][ "parts" ].append(data)
343
344
345 def _cost_anchor_matches (html: str ) -> list[tuple[ str , str ]]:
346 """Parse `html` and return every (data-cost-key, rendered text) pair found
347 outside comments and non-rendered markup (script/style/template/hidden),
348 including nested recognized anchors."""
349 parser = _CostAnchorParser()
350 parser.feed(html)
351 parser.close()
352 return parser.results
353
354
355 def _dig (d: dict , path: tuple[ str , ... ]):
356 cur = d
357 for key in path:
358 if not isinstance (cur, dict ) or key not in cur:
359 return None
360 cur = cur[key]
361 return cur
362
363
364 def _validate_cost_figures (html: str , migration_dir: Path | None ) -> list[ str ]:
365 """Assert the report's cost figures match estimation-infra.json (P1-C).
366
367 Fail direction:
368 - No estimation-infra.json / corrupt / not a dict -> skip (fail open on absence).
369 - aws_monthly_balanced present in JSON + exec-costs present -> an anchor MUST
370 exist INSIDE <section id="exec-costs">; a missing anchor there FAILs (an
371 un-anchored wrong figure, or an anchor placed elsewhere e.g. decision-summary,
372 must not pass) — mirrors validate-migration-report.py's required-anchors
373 section scoping.
374 - Anchor present anywhere + JSON value present but rendered dollars differ -> FAIL
375 (any anchor is still cross-checked against the estimate, even outside exec-costs).
376 - Anchored element with a real JSON value but no $ rendered -> FAIL.
377 - Non-numeric / non-whole-dollar JSON value -> FAIL (named), never a crash.
378 - Unknown anchor key -> skip.
379 """
380 if migration_dir is None :
381 return []
382 est_path = migration_dir / "estimation-infra.json"
383 if not est_path.is_file():
384 return []
385 try :
386 est = json.loads(est_path.read_text( encoding = "utf-8" ))
387 except ( OSError , json.JSONDecodeError):
388 return [] # fail open on ambiguity: a corrupt estimate does not gate the report
389 if not isinstance (est, dict ):
390 return []
391 errors: list[ str ] = []
392
393 for key, text in _cost_anchor_matches(html):
394 key = key.lower()
395 path = _COST_ANCHORS .get(key)
396 if path is None :
397 continue
398 expected = _dig(est, path)
399 if expected is None :
400 continue
401 try :
402 # Normalize the JSON figure to the SAME display precision the emitter
403 # renders at (nearest dollar for monthly-scale, cents for small
404 # totals) so a correctly rounded report matches — not a truncation.
405 expected_dollars = _canonical_money( float (expected))
406 except ( TypeError , ValueError ):
407 errors.append(
408 f 'estimation-infra.json { "." .join(path) } is not a numeric dollar '
409 f "amount: { expected !r} "
410 )
411 continue
412 rendered = _normalize_money(text)
413 if rendered is None :
414 errors.append(
415 f 'data-cost-key=" { key } " element renders no dollar amount '
416 f "(expected $ { expected_dollars } from { '.' .join(path) } )"
417 )
418 continue
419 if expected_dollars != rendered:
420 errors.append(
421 f 'cost figure mismatch: data-cost-key=" { key } " renders "$ { rendered } " '
422 f 'but estimation-infra.json { "." .join(path) } = $ { expected_dollars } '
423 )
424
425 # Required figures must be anchored INSIDE <section id="exec-costs"> when their
426 # JSON value exists and that section is rendered. A redundant anchor elsewhere
427 # (e.g. a decision-summary hero metric) is still cross-checked by the mismatch
428 # loop above, but does not satisfy this requirement: exec-costs is the section
429 # customers read as the authoritative cost comparison.
430 exec_costs_html = _section_html(html, "exec-costs" )
431 if exec_costs_html is not None :
432 exec_costs_keys = {k.lower() for k, _ in _cost_anchor_matches(exec_costs_html)}
433 for key in _REQUIRED_COST_KEYS :
434 path = _COST_ANCHORS [key]
435 if _dig(est, path) is None :
436 continue
437 if key not in exec_costs_keys:
438 errors.append(
439 f 'missing data-cost-key=" { key } " anchor inside '
440 f '<section id="exec-costs">; cannot confirm the rendered figure '
441 f "matches estimation-infra.json { '.' .join(path) } (wrap that "
442 f 'figure in <span data-cost-key=" { key } ">...</span> inside '
443 f "exec-costs)"
444 )
445
446 return errors
447
448
449 def _body_scope (html: str ) -> str :
450 """Body only, excluding <style> blocks, so CSS hex/decimal values never
451 trip the currency-formatting check (mirrors the GCP validator's
452 _readability_scope)."""
453 no_style = re.sub( r "<style \b. *? </style>" , "" , html, flags = re. DOTALL | re. IGNORECASE )
454 body = re.search( r "<body \b[ ^> ] * > (. *? ) </body>" , no_style, re. DOTALL | re. IGNORECASE )
455 return body.group( 1 ) if body else no_style
456
457
458 # Ported from validate-migration-report.py — same currency-formatting rule
459 # (monthly figures render as whole dollars; cents are reserved for genuinely
460 # sub-dollar precision or per-unit rates). See that file's comment for the
461 # full rationale; kept identical here so both validators stay in sync. The
462 # Heroku report has no documented Calculation/Notes column (its exec-costs
463 # section is a Heroku-vs-AWS side-by-side or three-tier table, per
464 # generate-report.md — no per-service arithmetic show-work column), so this
465 # copy has no calc-column exemption; everything else ports unchanged.
466 CENTS_RE = re.compile( r " \$ ([ 0-9 ][ 0-9, ] * ) \. ([ 0-9 ] {2} )\b " )
467
468 # Deliberately does NOT accept a BARE "month"/"mo" as itself the qualifying
469 # unit — see validate-migration-report.py's _RATE_SUFFIX_RE comment for the
470 # full rationale (a bare "/mo" is exactly the unit an ordinary monthly total
471 # is denominated in, not evidence of a per-unit rate). "/mo per <unit>" is
472 # still accepted (e.g. "$5.00/mo per policy").
473 _RATE_SUFFIX_RE = re.compile(
474 r " ^\s * (?: / | \( | \b per \b) ? \s * (?: mo \b\s * (?: per \b\s * ) ? ) ? "
475 r " (?: hr | hour | hourly | vcpu | gb | gib | tb | image | unit | policy | 1m | 10k | "
476 r " [ 0-9 ] + -mo) \b " ,
477 re. IGNORECASE ,
478 )
479
480 _CENTS_MEANINGFUL_BELOW = 2
481
482
483 class _DecodedTextParser ( HTMLParser ):
484 """Extract rendered text as the browser would present it — entities
485 decoded, comments and inert content (script/style/template) excluded —
486 while preserving amount/unit adjacency across inline markup (mirrors
487 validate-migration-report.py's _DecodedTextRunParser; see that file's
488 class docstring for the full rationale). No Calculation/Notes column
489 tracking here — the Heroku report has no such column."""
490
491 _INLINE_TAGS = {
492 "a" , "abbr" , "b" , "bdi" , "bdo" , "cite" , "code" , "data" , "dfn" , "em" ,
493 "i" , "kbd" , "mark" , "q" , "s" , "samp" , "small" , "span" , "strong" ,
494 "sub" , "sup" , "time" , "u" , "var" , "wbr" ,
495 }
496 _INERT_TAGS = { "script" , "style" , "template" }
497
498 def __init__ (self) -> None :
499 super (). __init__ ( convert_charrefs = True )
500 self ._parts: list[ str ] = []
501 self ._inert_depth = 0
502 # Absolute offsets (into text()) of every block-level separator — a
503 # rate-suffix match must never read past one of these into unrelated
504 # content from a different cell/row/paragraph (see
505 # validate-migration-report.py's identical tracking for the full
506 # rationale — a single separating space does not itself stop a
507 # word-based regex when the next block happens to start with a real
508 # rate-unit word like "Hourly").
509 self ._boundaries: list[ int ] = []
510
511 def _append (self, text: str , * , is_boundary: bool = False ) -> None :
512 if not text:
513 return
514 if is_boundary:
515 self ._boundaries.append( sum ( len (p) for p in self ._parts))
516 self ._parts.append(text)
517
518 def handle_starttag (self, tag: str , attrs: list[tuple[ str , str | None ]]) -> None :
519 if tag in self . _INERT_TAGS :
520 self ._inert_depth += 1
521 return
522 if self ._inert_depth == 0 and tag not in self . _INLINE_TAGS :
523 self ._append( " " , is_boundary = True )
524
525 def handle_startendtag (self, tag: str , attrs: list[tuple[ str , str | None ]]) -> None :
526 if tag not in self . _INLINE_TAGS and tag not in self . _INERT_TAGS :
527 self ._append( " " , is_boundary = True )
528
529 def handle_endtag (self, tag: str ) -> None :
530 if tag in self . _INERT_TAGS :
531 if self ._inert_depth > 0 :
532 self ._inert_depth -= 1
533 return
534 if self ._inert_depth == 0 and tag not in self . _INLINE_TAGS :
535 self ._append( " " , is_boundary = True )
536
537 def handle_data (self, data: str ) -> None :
538 if self ._inert_depth == 0 :
539 self ._append(data)
540
541 def text (self) -> str :
542 return "" .join( self ._parts)
543
544 def boundaries (self) -> list[ int ]:
545 return self ._boundaries
546
547
548 def _decoded_text (html: str ) -> tuple[ str , list[ int ]]:
549 parser = _DecodedTextParser()
550 parser.feed(_body_scope(html))
551 parser.close()
552 return parser.text(), parser.boundaries()
553
554
555 def _validate_currency_formatting (html: str ) -> list[ str ]:
556 """Monthly cost figures must render as whole dollars. Flag any $X.YY
557 figure whose whole-dollar part is >= $2 and that is not immediately
558 followed by a per-unit-rate suffix (/hr, per policy, etc.)."""
559 errors: list[ str ] = []
560 text, boundaries = _decoded_text(html)
561 seen: set[ str ] = set ()
562 for match in CENTS_RE .finditer(text):
563 whole = int (match.group( 1 ).replace( "," , "" ))
564 if whole < _CENTS_MEANINGFUL_BELOW :
565 continue
566 cutoff = match.end() + 25
567 for boundary in boundaries:
568 if boundary >= match.end():
569 cutoff = min (cutoff, boundary)
570 break
571 trailing = text[match.end():cutoff]
572 if _RATE_SUFFIX_RE .match(trailing):
573 continue
574 token = match.group( 0 )
575 if token in seen:
576 continue
577 seen.add(token)
578 errors.append(
579 f 'currency formatting: " { token } " renders cents on a monthly-scale '
580 "figure — round to a whole dollar (cents only for genuinely "
581 'sub-dollar precision, e.g. "$1.50", "$0.40", or a per-unit rate '
582 'like "$0.018/hr")'
583 )
584 return errors
585
586
587 class _TagAttrCollector ( HTMLParser ):
588 """Collect (tag, attrs-dict, is_self_closed) for every RENDERED start tag,
589 using the stdlib parser rather than a literal-syntax regex. This accepts any
590 legal HTML attribute spelling — quoted or unquoted values, spaces around `=`,
591 single or double quotes — instead of only the exact `name="value"` form a
592 hand-rolled regex happens to match.
593
594 Tags inside an inert subtree (`<script>`, `<style>`, `<template>`) are
595 skipped — the browser never renders them, so a class/attribute declared only
596 there (e.g. a `verdict-headline` inside a `<template>`) must not be read as a
597 rendered element. Mirrors _DecodedTextParser's inert handling so every
598 "is this rendered?" check in this file agrees on the answer."""
599
600 _INERT_TAGS = { "script" , "style" , "template" }
601
602 def __init__ (self) -> None :
603 super (). __init__ ( convert_charrefs = True )
604 self .tags: list[tuple[ str , dict[ str , str | None ], bool ]] = []
605 self ._inert_depth = 0
606
607 def handle_starttag (self, tag: str , attrs: list[tuple[ str , str | None ]]) -> None :
608 if tag in self . _INERT_TAGS :
609 self ._inert_depth += 1
610 return
611 if self ._inert_depth == 0 :
612 self .tags.append((tag, dict (attrs), False ))
613
614 def handle_startendtag (self, tag: str , attrs: list[tuple[ str , str | None ]]) -> None :
615 # A self-closed inert tag opens no subtree; a self-closed normal tag is a
616 # rendered element (unless nested in an inert subtree).
617 if tag in self . _INERT_TAGS :
618 return
619 if self ._inert_depth == 0 :
620 self .tags.append((tag, dict (attrs), True ))
621
622 def handle_endtag (self, tag: str ) -> None :
623 if tag in self . _INERT_TAGS and self ._inert_depth > 0 :
624 self ._inert_depth -= 1
625
626
627 def _collect_tags (html: str ) -> list[tuple[ str , dict[ str , str | None ], bool ]]:
628 parser = _TagAttrCollector()
629 parser.feed(html)
630 parser.close()
631 return parser.tags
632
633
634 def _html_lang_declared (html: str ) -> bool :
635 for tag, attrs, _ in _collect_tags(html):
636 if tag != "html" :
637 continue
638 lang = attrs.get( "lang" )
639 return bool (lang) and bool (re.fullmatch( r " [ a-z ] {2} (?: - [ A-Za-z0-9 ] + ) ? " , lang, re. IGNORECASE ))
640 return False
641
642
643 NO_ELIGIBLE_COMMITMENT_SENTENCE = (
644 "no 1-year/3-year commitment product applies to this architecture"
645 )
646
647
648 class _OpportunityRowParser ( HTMLParser ):
649 """Stdlib-parser scan for a populated <td> inside a <table> — parses actual
650 table structure rather than matching raw HTML source, so it:
651 - decodes character references (convert_charrefs=True) before text is
652 seen, so a cell containing only " "/" " is correctly treated
653 as blank rather than non-empty literal markup;
654 - never sees text inside HTML comments (HTMLParser's tokenizer routes
655 comments to handle_comment, not handle_data), so a commented-out
656 <tr>...</tr> can never register as a populated row;
657 - tracks "inside <table>, inside <tr>, inside <td>" state directly,
658 without requiring an explicit <tbody> — a <table><tr><td> with no
659 <tbody> is valid HTML (browsers infer an implicit tbody) and must be
660 treated the same as one with an explicit <tbody>.
661 <th> cells are deliberately excluded — header rows never count as an
662 opportunity row, regardless of tbody/thead placement.
663
664 Inert subtrees (`<script>`, `<style>`, `<template>`) are skipped entirely:
665 text inside them is never rendered, so a cell whose only content is a
666 `<template>`/`<script>`, or a whole table nested in a `<template>`, must not
667 register as a populated opportunity row. Mirrors _DecodedTextParser's inert
668 handling so this check agrees with the fallback-sentence check on what
669 counts as rendered content."""
670
671 _INERT_TAGS = { "script" , "style" , "template" }
672
673 def __init__ (self) -> None :
674 super (). __init__ ( convert_charrefs = True )
675 self .found = False
676 self ._table_depth = 0
677 self ._tr_depth = 0
678 self ._td_open = False # a <td> is currently open (its end tag may be omitted)
679 self ._td_has_text = False
680 self ._inert_depth = 0 # >0 while inside script/style/template
681
682 def _in_table (self) -> bool :
683 return self ._table_depth > 0
684
685 def _close_cell (self) -> None :
686 """Finalize whatever <td> is currently open, exactly as a real HTML
687 parser would when the cell's end tag is omitted: the HTML Standard
688 permits a <td> end tag to be omitted immediately before the next
689 <td>/<th>, or before its parent <tr>/<table> closes. Only checking
690 text on an EXPLICIT handle_endtag("td") missed every cell written
691 with an omitted end tag (e.g. `<table><tr><td>text</tr></table>`,
692 which never fires handle_endtag("td") at all) — a populated,
693 perfectly valid compact table would then silently register as
694 empty."""
695 if self ._td_open and self ._td_has_text:
696 self .found = True
697 self ._td_open = False
698 self ._td_has_text = False
699
700 def handle_starttag (self, tag: str , attrs: list[tuple[ str , str | None ]]) -> None :
701 if tag in self . _INERT_TAGS :
702 self ._inert_depth += 1
703 return
704 if self ._inert_depth > 0 :
705 return # table/row/cell structure inside an inert subtree is not rendered
706 if tag == "table" :
707 self ._table_depth += 1
708 elif tag == "tr" and self ._in_table():
709 self ._close_cell() # any cell open from a previous row must not leak across rows
710 self ._tr_depth += 1
711 elif tag in ( "td" , "th" ) and self ._tr_depth > 0 :
712 self ._close_cell() # a new cell always implicitly closes any sibling cell still open
713 if tag == "td" :
714 self ._td_open = True
715
716 def handle_startendtag (self, tag: str , attrs: list[tuple[ str , str | None ]]) -> None :
717 if tag in self . _INERT_TAGS or self ._inert_depth > 0 :
718 return
719 if tag in ( "td" , "th" ) and self ._tr_depth > 0 :
720 self ._close_cell() # a self-closed <td/> or <th/> can never carry text content
721
722 def handle_endtag (self, tag: str ) -> None :
723 if tag in self . _INERT_TAGS :
724 if self ._inert_depth > 0 :
725 self ._inert_depth -= 1
726 return
727 if self ._inert_depth > 0 :
728 return
729 if tag in ( "td" , "th" ):
730 self ._close_cell()
731 elif tag == "tr" :
732 self ._close_cell()
733 if self ._tr_depth > 0 :
734 self ._tr_depth -= 1
735 elif tag == "table" :
736 self ._close_cell()
737 if self ._table_depth > 0 :
738 self ._table_depth -= 1
739
740 def handle_data (self, data: str ) -> None :
741 if self ._inert_depth == 0 and self ._td_open and data.strip():
742 self ._td_has_text = True
743
744
745 def _has_populated_opportunity_row (body: str ) -> bool :
746 """True if any <tr> inside a <table> has a <td> (not <th>) with non-empty
747 text content — i.e. an actual opportunity row, not just a header row, an
748 empty cell, a whitespace-only/entity-only cell, or a commented-out row."""
749 parser = _OpportunityRowParser()
750 parser.feed(body)
751 parser.close()
752 return parser.found
753
754
755 def _validate_optimization_content (body: str ) -> list[ str ]:
756 """The cost-optimization section must render EITHER a populated opportunities
757 table (a real <tbody> row with data) OR the explicit no-eligible-commitment
758 sentence — nothing else counts as content. This is a positive check, not
759 "any leftover text after stripping headings/<th>": that older approach let a
760 heading, column headers, AND any other prose (e.g. the credits disclaimer
761 that accompanies a populated table, or explanatory filler) satisfy the
762 requirement even with an empty <tbody>. Only a real opportunity row or the
763 literal no-eligible sentence can pass."""
764 if _has_populated_opportunity_row(body):
765 return []
766 # Extract the RENDERED text (entities decoded, comments and inert
767 # script/style/template excluded) rather than raw-regex-stripping tags — so
768 # the fallback sentence is recognized when written with entity escapes
769 # (`1-year`, ` ` between words) and is NOT satisfied by a copy that
770 # exists only inside an HTML comment or <template> the browser never renders.
771 # Reuses the same _DecodedTextParser the currency check uses, so both agree
772 # on what "rendered" means.
773 parser = _DecodedTextParser()
774 parser.feed(body)
775 parser.close()
776 # Normalize decoded whitespace (incl. NBSP U+00A0 from ` `) to single
777 # spaces so an entity-separated sentence matches the literal one.
778 text = re.sub( r " \s + " , " " , parser.text().replace( " \u00a0 " , " " )).strip().lower()
779 if NO_ELIGIBLE_COMMITMENT_SENTENCE in text:
780 return []
781 return [
782 '<section id="cost-optimization"> has no substantive content — render a '
783 "populated opportunity row (not just column headers, an empty <tbody>, or "
784 "other prose like the credits disclaimer) or the exact "
785 f '" { NO_ELIGIBLE_COMMITMENT_SENTENCE } " sentence, never a blank, '
786 "heading-only, or table-header-only section"
787 ]
788
789
790 def _class_tokens (html_fragment: str ) -> set[ str ]:
791 """Return the set of all `class` attribute tokens across every tag in the
792 fragment, parsed via the stdlib HTMLParser (the same approach already used
793 for lang/scope/aria-label) rather than a literal `class="value"` regex. This
794 accepts any legal spelling — `class="a b"`, `class = "a b"`, or an unquoted
795 single token like `class=verdict-headline` — so equivalent HTML always
796 produces the same tokens regardless of formatting."""
797 tokens: set[ str ] = set ()
798 for _, attrs, _ in _collect_tags(html_fragment):
799 cls = attrs.get( "class" )
800 if cls :
801 tokens.update( cls .split())
802 return tokens
803
804
805 def _validate_verdict (html: str , migration_dir: Path | None ) -> list[ str ]:
806 """Typography-first verdict rules (skill: verdict is the section thesis and
807 must never be a colored-pill row)."""
808 errors: list[ str ] = []
809 summary = _section_html(html, "decision-summary" )
810 if summary is None :
811 return errors # missing-section already reported by the required-ID check
812
813 summary_classes = _class_tokens(summary)
814
815 # Colored pill badges are banned outright (not merely as the "sole" carrier).
816 if any (token.startswith( "badge-verdict-" ) for token in summary_classes):
817 errors.append(
818 'decision-summary must use a typography-first verdict-headline, '
819 "not badge-verdict-* pills (meaning must not depend on color alone)"
820 )
821
822 # When Estimate declared a recommendation outcome, the verdict headline is required.
823 recommendation_outcome = False
824 if migration_dir is not None :
825 est_path = migration_dir / "estimation-infra.json"
826 if est_path.is_file():
827 try :
828 est = json.loads(est_path.read_text( encoding = "utf-8" ))
829 rec = (est or {}).get( "recommendation" ) or {}
830 recommendation_outcome = bool (rec.get( "outcome" ))
831 except ( OSError , json.JSONDecodeError):
832 # Fail open on ambiguity: a missing/corrupt estimate does not force the
833 # verdict-headline requirement (we can't confirm an outcome was declared).
834 recommendation_outcome = False
835 if recommendation_outcome and "verdict-headline" not in summary_classes:
836 errors.append(
837 "estimation-infra.json declares recommendation.outcome but "
838 "decision-summary has no verdict-headline element "
839 '(render outcome_label as <p class="verdict-headline">…</p>)'
840 )
841 return errors
842
843
844 class _AccessibilityParser ( HTMLParser ):
845 """Stdlib-parser accessibility scan: <th scope> and <figure aria-label> +
846 <figcaption>. Using the parser (rather than a literal `name="value"` regex)
847 accepts any legal HTML attribute syntax — `scope=col`, `scope = "col"`,
848 single quotes, etc. — the same attribute in different valid spellings must
849 not flip a validator result. VOID_ELEMENTS avoids mis-tracking self-closing
850 tags (e.g. <br>) as unclosed ancestors."""
851
852 VOID_ELEMENTS = {
853 "area" , "base" , "br" , "col" , "embed" , "hr" , "img" , "input" ,
854 "link" , "meta" , "param" , "source" , "track" , "wbr" ,
855 }
856 _INERT_TAGS = { "script" , "style" , "template" }
857
858 def __init__ (self) -> None :
859 super (). __init__ ( convert_charrefs = True )
860 self .stack: list[ str ] = []
861 self .table_issues: list[ int ] = [] # 1-based table index with a scope-less <th>
862 self .figure_issues: list[tuple[ int , bool , bool ]] = [] # (index, has_aria_label, has_figcaption)
863 self ._table_index = 0
864 self ._figure_index = 0
865 self ._table_depth = 0 # >0 while inside a <table> (nesting-tolerant)
866 self ._table_bad_at: dict[ int , bool ] = {}
867 self ._figure_stack: list[dict[ str , bool | None ]] = []
868 self ._inert_depth = 0 # >0 while inside script/style/template
869
870 def _in_table (self) -> bool :
871 return self ._table_depth > 0
872
873 def handle_starttag (self, tag: str , attrs: list[tuple[ str , str | None ]]) -> None :
874 if tag in self . _INERT_TAGS :
875 self ._inert_depth += 1
876 return
877 if self ._inert_depth > 0 :
878 return # a <th>/<figure> inside an inert subtree is not rendered — do not audit it
879 attr_map = dict (attrs)
880 if tag == "table" :
881 self ._table_depth += 1
882 if self ._table_depth == 1 :
883 self ._table_index += 1
884 self ._table_bad_at[ self ._table_index] = False
885 elif tag == "th" and self ._in_table():
886 scope = attr_map.get( "scope" )
887 if not (scope and scope.lower() in ( "col" , "row" )):
888 self ._table_bad_at[ self ._table_index] = True
889 elif tag == "figure" :
890 self ._figure_index += 1
891 aria_label = attr_map.get( "aria-label" )
892 self ._figure_stack.append(
893 {
894 "index" : self ._figure_index,
895 "has_aria_label" : bool (aria_label and aria_label.strip()),
896 "has_figcaption" : False ,
897 }
898 )
899 elif tag == "figcaption" and self ._figure_stack:
900 self ._figure_stack[ - 1 ][ "has_figcaption" ] = True
901 if tag not in self . VOID_ELEMENTS :
902 self .stack.append(tag)
903
904 def handle_startendtag (self, tag: str , attrs: list[tuple[ str , str | None ]]) -> None :
905 # Self-closed tags never open a scope for descendants (e.g. a self-closed
906 # <figure /> can never contain a <figcaption>) — handled naturally since
907 # we don't push onto self.stack or self._figure_stack for these.
908 pass
909
910 def handle_endtag (self, tag: str ) -> None :
911 if tag in self . _INERT_TAGS :
912 if self ._inert_depth > 0 :
913 self ._inert_depth -= 1
914 return
915 if self ._inert_depth > 0 :
916 return
917 if tag == "table" and self ._table_depth > 0 :
918 self ._table_depth -= 1
919 if self ._table_depth == 0 :
920 if self ._table_bad_at.get( self ._table_index):
921 self .table_issues.append( self ._table_index)
922 elif tag == "figure" and self ._figure_stack:
923 fig = self ._figure_stack.pop()
924 self .figure_issues.append(
925 (fig[ "index" ], fig[ "has_aria_label" ], fig[ "has_figcaption" ])
926 )
927 while self .stack and tag in self .stack:
928 popped = self .stack.pop()
929 if popped == tag:
930 break
931
932
933 def _validate_accessibility (html: str ) -> list[ str ]:
934 """Dependency-free WCAG-oriented semantics — the safe subset the Heroku
935 one-pager emits (no single-<h1> / per-table-<caption> requirement)."""
936 errors: list[ str ] = []
937 if not _html_lang_declared(html):
938 errors.append( "accessibility: <html> must declare a valid lang attribute" )
939
940 body = re.sub( r "<style \b. *? </style>" , "" , html, flags = re. IGNORECASE | re. DOTALL )
941 parser = _AccessibilityParser()
942 parser.feed(body)
943 parser.close()
944
945 for index in parser.table_issues:
946 errors.append(
947 f 'accessibility: table { index } header cells must declare '
948 'scope="col" or scope="row"'
949 )
950
951 for index, has_aria_label, has_figcaption in parser.figure_issues:
952 if not has_aria_label:
953 errors.append( f "accessibility: figure { index } must have an aria-label" )
954 if not has_figcaption:
955 errors.append( f "accessibility: figure { index } must include a <figcaption>" )
956 return errors
957
958
959 def validate (html: str , migration_dir: Path | None , mode: str = "full" ) -> list[ str ]:
960 errors: list[ str ] = []
961 counts = _section_counts(html)
962
963 for sid in _required_sections(mode):
964 n = counts.get(sid, 0 )
965 if n == 0 :
966 errors.append( f 'missing required <section id=" { sid } ">' )
967 elif n > 1 :
968 errors.append( f 'duplicate <section id=" { sid } "> ( { n } occurrences)' )
969
970 # The other mode's terminal section must NOT appear — decision-report.html must
971 # not carry a next-steps pointer into an execution pack that does not exist yet,
972 # and migration-report.html must not carry the pre-execution decision-cta once the
973 # real next-steps exists.
974 other_mode = "decision" if mode == "full" else "full"
975 other_terminal = MODE_REQUIRED_SECTION_ID [other_mode]
976 if counts.get(other_terminal, 0 ) >= 1 :
977 errors.append(
978 f '--mode { mode } report must not contain <section id=" { other_terminal } "> '
979 f "(that is the { other_mode } -mode terminal section)"
980 )
981
982 if "draft for review" not in html.lower():
983 errors.append( 'footer must contain "draft for review" disclaimer' )
984
985 errors.extend(_validate_currency_formatting(html))
986 errors.extend(_validate_cost_figures(html, migration_dir))
987
988 # cost-optimization must carry substantive content (generate-report.md Step 3
989 # item 6: a table of real opportunity rows, or the explicit no-eligible-commitment
990 # sentence — never empty, and never satisfied by a heading or table-header text
991 # alone). A heading like "Cost Optimization Opportunities" or a table with only
992 # column headers and an empty <tbody> must not pass as content. Full mode only —
993 # the decision pack carries no optimization table.
994 if mode == "full" and counts.get( "cost-optimization" , 0 ) >= 1 :
995 body = _section_html(html, "cost-optimization" ) or ""
996 errors.extend(_validate_optimization_content(body))
997
998 errors.extend(_validate_verdict(html, migration_dir))
999 errors.extend(_validate_accessibility(html))
1000
1001 if migration_dir is not None :
1002 index_path = migration_dir / "scenarios" / "index.json"
1003 if index_path.is_file():
1004 try :
1005 index = json.loads(index_path.read_text( encoding = "utf-8" ))
1006 except ( OSError , json.JSONDecodeError):
1007 index = None
1008 scenarios = (index or {}).get( "scenarios" ) or []
1009 if len (scenarios) >= 2 and counts.get( "what-if-scenarios" , 0 ) < 1 :
1010 errors.append(
1011 'scenarios/index.json has ≥2 scenarios but no '
1012 '<section id="what-if-scenarios">'
1013 )
1014
1015 # generate-report.md / report-decision-core.md § decision-basis: when Estimate
1016 # declared decision_basis (evidence/assumptions behind the verdict), the report
1017 # MUST render it — in both modes, since decision mode reuses these exact content
1018 # rules. Read the same estimation-infra.json the report was built from, so a
1019 # report that silently drops decision-basis cannot still say REPORT_OK.
1020 est_path = migration_dir / "estimation-infra.json"
1021 if est_path.is_file():
1022 try :
1023 est = json.loads(est_path.read_text( encoding = "utf-8" ))
1024 except ( OSError , json.JSONDecodeError):
1025 est = None
1026 decision_basis = ((est or {}).get( "recommendation" ) or {}).get( "decision_basis" )
1027 if decision_basis and counts.get( "decision-basis" , 0 ) < 1 :
1028 errors.append(
1029 "estimation-infra.json declares recommendation.decision_basis "
1030 'but the report has no <section id="decision-basis"> '
1031 '("What This Assessment Rests On")'
1032 )
1033
1034 if mode == "decision" and migration_dir is not None :
1035 # Decision mode's invariant: THIS decide-complete cycle has not itself gone
1036 # through Generate yet (phases.generate is "pending"/absent). It is NOT "no
1037 # terraform/ file exists" — a prior Generate/workshop-reprice cycle's execution
1038 # pack can legitimately still be on disk (workshop re-entry preserves it). The
1039 # signal is .phase-status.json's own bookkeeping, not the filesystem.
1040 #
1041 # Fail open ONLY on a genuinely MISSING status file (no run ever tracked state
1042 # here, e.g. the isolated unit-test path). Do NOT fail open on a file that
1043 # EXISTS but is unreadable/invalid JSON: that is corruption, and INTERPRETER.md
1044 # § State-file validation says invalid JSON is a STOP condition, not "no state."
1045 phase_path = migration_dir / ".phase-status.json"
1046 generate_status: str | None = None
1047 if phase_path.is_file():
1048 try :
1049 phase_text = phase_path.read_text( encoding = "utf-8" )
1050 if not phase_text.strip():
1051 raise json.JSONDecodeError( "empty file" , phase_text, 0 )
1052 phase = json.loads(phase_text)
1053 except ( OSError , json.JSONDecodeError) as exc:
1054 errors.append(
1055 "decision mode: .phase-status.json exists but could not be "
1056 f "read/parsed ( { exc } ) — state corrupted (invalid JSON). Delete the "
1057 "file and restart the current phase (INTERPRETER.md § State-file "
1058 "validation); an unreadable state file is not evidence of a "
1059 "pre-execution decision"
1060 )
1061 else :
1062 generate_status = (phase or {}).get( "phases" , {}).get( "generate" )
1063 if generate_status in ( "completed" , "in_progress" ):
1064 errors.append(
1065 "decision mode: .phase-status.json phases.generate is "
1066 f " { generate_status !r} — this decide-complete cycle already went through "
1067 "Generate; decision mode is pre-execution only for the CURRENT cycle (a "
1068 "prior cycle's execution pack may legitimately remain on disk after a "
1069 "workshop reprice)"
1070 )
1071
1072 return errors
1073
1074
1075 def main () -> int :
1076 parser = argparse.ArgumentParser( description = __doc__ )
1077 parser.add_argument( "report_path" , type = Path)
1078 parser.add_argument( "--migration-dir" , type = Path, default = None )
1079 parser.add_argument(
1080 "--mode" ,
1081 choices = [ "full" , "decision" ],
1082 default = "full" ,
1083 help = "full = migration-report.html (default); decision = decision-report.html" ,
1084 )
1085 args = parser.parse_args()
1086
1087 if not args.report_path.is_file():
1088 print ( f "REPORT_FAIL | file= { args.report_path } | reason=not_found" , file = sys.stderr)
1089 return 1
1090
1091 html = args.report_path.read_text( encoding = "utf-8" )
1092 errors = validate(html, args.migration_dir, args.mode)
1093 if errors:
1094 print ( f "REPORT_FAIL | file= { args.report_path } | errors= { len (errors) } " , file = sys.stderr)
1095 for err in errors:
1096 print ( f " - { err } " , file = sys.stderr)
1097 return 1
1098
1099 counts = _section_counts(html)
1100 optional = []
1101 if counts.get( "what-if-scenarios" , 0 ) >= 1 :
1102 optional.append( "what-if-scenarios" )
1103 required = _required_sections(args.mode)
1104 print (
1105 "REPORT_OK | structure=complete | sections="
1106 f " { len (required) } / { len (required) } "
1107 + ( f " | optional= { ',' .join(optional) } " if optional else "" )
1108 )
1109 return 0
1110
1111
1112 if __name__ == "__main__" :
1113 sys.exit(main())