From 432a3c8ebfb50d465b09b147256c74fe10ab0b04 Mon Sep 17 00:00:00 2001 From: Logan Kleier Date: Thu, 1 Oct 2026 13:08:47 -0700 Subject: [PATCH 01/10] fix(aws-startup-advisor): reject migration reports that skip the decision core REPORT_OK treated section IDs as completeness, so a thin HTML report could pass without the verdict, flip conditions, or architecture section. The shared validator now checks those artifact-backed fields for GCP and Azure, and the Heroku validator checks the flip list and what-if columns. Co-authored-by: Cursor --- .../fixtures/migration-report-reference.html | 6 + ...validate_migration_report_decision_core.py | 93 ++++++++ .../scripts/validate-migration-report.py | 210 ++++++++++++++++++ .../generate/generate-artifacts-report.md | 16 +- .../references/phases/generate/generate.md | 2 +- .../generate/generate-artifacts-report.md | 18 +- .../references/phases/generate/generate.md | 2 +- .../shared/validate-migration-report.md | 12 +- .../phases/generate/generate-report.md | 11 +- .../test_validate_heroku_migration_report.py | 57 +++++ .../validate-heroku-migration-report.py | 42 ++++ 11 files changed, 455 insertions(+), 14 deletions(-) create mode 100644 plugins/aws-startup-advisor/scripts/test_validate_migration_report_decision_core.py diff --git a/plugins/aws-startup-advisor/fixtures/migration-report-reference.html b/plugins/aws-startup-advisor/fixtures/migration-report-reference.html index 6f6b0a3b3..aa1027f49 100644 --- a/plugins/aws-startup-advisor/fixtures/migration-report-reference.html +++ b/plugins/aws-startup-advisor/fixtures/migration-report-reference.html @@ -170,6 +170,12 @@

Stay entirely if

  • Cost is the only metric and a dual-run period must be avoided
  • +

    What would flip this

    + +

    Key decisions ahead

    1. Target region and deployment model (Fargate vs EKS)
    2. diff --git a/plugins/aws-startup-advisor/scripts/test_validate_migration_report_decision_core.py b/plugins/aws-startup-advisor/scripts/test_validate_migration_report_decision_core.py new file mode 100644 index 000000000..da8f879e1 --- /dev/null +++ b/plugins/aws-startup-advisor/scripts/test_validate_migration_report_decision_core.py @@ -0,0 +1,93 @@ +"""Decision-core content checks for the shared migration-report validator. + +A report used to pass when the section IDs existed, even if it never rendered +the verdict headline, flip conditions, specialist callout, or architecture +section. These tests lock the artifact-gated checks that reject that report. +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parent.parent +SCRIPT = PLUGIN / "scripts" / "validate-migration-report.py" +FIXTURES = PLUGIN / "fixtures" + + +def _load(): + spec = importlib.util.spec_from_file_location("validate_migration_report", SCRIPT) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _reference_html() -> str: + return (FIXTURES / "migration-report-reference.html").read_text(encoding="utf-8") + + +def _reference_estimate() -> dict: + return json.loads( + (FIXTURES / "estimation-infra-reference.json").read_text(encoding="utf-8") + ) + + +def test_reference_fixture_still_passes() -> None: + validator = _load() + ai = json.loads((FIXTURES / "estimation-ai-reference.json").read_text(encoding="utf-8")) + errors = validator.validate_report( + _reference_html(), + _reference_estimate(), + ai, + ) + assert errors == [], errors + + +def test_missing_would_flip_list_fails() -> None: + validator = _load() + html = _reference_html().replace("

      What would flip this

      ", "

      Notes

      ") + errors = validator.validate_report(html, _reference_estimate(), None) + assert any("would_flip" in err for err in errors), errors + + +def test_deferred_service_without_callout_fails() -> None: + validator = _load() + html = _reference_html().replace("specialist engagement", "analytics follow-up") + html = html.replace("Specialist engagement", "Analytics follow-up") + design = { + "clusters": [ + { + "resources": [ + {"aws_service": "Deferred — specialist engagement"}, + ] + } + ] + } + errors = validator.validate_report(html, _reference_estimate(), None, design) + assert any("specialist" in err.lower() for err in errors), errors + + +def test_clusters_without_architecture_section_fail() -> None: + validator = _load() + html = _reference_html().replace('id="exec-architecture"', 'id="not-architecture"') + design = {"clusters": [{"resources": [{"aws_service": "Amazon S3"}]}]} + errors = validator.validate_report(html, _reference_estimate(), None, design) + assert any("exec-architecture" in err for err in errors), errors + + +def test_decision_fixture_still_passes() -> None: + validator = _load() + root = FIXTURES / "gcp-decision-gate" / "after-decide-complete" + html = (root / "decision-report.html").read_text(encoding="utf-8") + estimate = json.loads((root / "estimation-infra.json").read_text(encoding="utf-8")) + errors = validator.validate_report( + html, + estimate, + None, + None, + migration_dir=root, + mode="decision", + ) + assert errors == [], errors diff --git a/plugins/aws-startup-advisor/scripts/validate-migration-report.py b/plugins/aws-startup-advisor/scripts/validate-migration-report.py index 1a77146da..d64f803d9 100644 --- a/plugins/aws-startup-advisor/scripts/validate-migration-report.py +++ b/plugins/aws-startup-advisor/scripts/validate-migration-report.py @@ -1246,6 +1246,14 @@ def _validate_appendix_config(html: str) -> list[str]: errors.append( 'appendix-config table must include a "Question" or "Assumption" column (from preferences.prompt)' ) + if "choice" not in hdr_text: + errors.append( + 'appendix-config table must include a "Your choice" column (the value the reader confirmed)' + ) + if "source" not in hdr_text: + errors.append( + 'appendix-config table must include a "Source" column (user answer, extracted, or default)' + ) if "consequence" not in hdr_text: errors.append( "appendix-config table must include a Design consequence column (from preferences.design_consequence)" @@ -1646,6 +1654,194 @@ def _validate_fixture_bleed(html: str, migration_dir: Path | None) -> list[str]: return errors +def _plain_text(fragment: str) -> str: + """Decoded visible text, with dashes folded so artifact prose can be matched.""" + text = unescape(re.sub(r"<[^>]+>", " ", fragment)) + text = text.replace("\u2014", "-").replace("\u2013", "-") + return re.sub(r"\s+", " ", text).strip().lower() + + +def _word_shingles(text: str, size: int = 4) -> set[str]: + """Word phrases used to recognize a rendered condition without requiring a verbatim copy.""" + words = re.findall(r"[a-z0-9]+", text.lower()) + if not words: + return set() + if len(words) <= size: + return {" ".join(words)} + return {" ".join(words[i : i + size]) for i in range(len(words) - size + 1)} + + +def _iter_aws_services(node: object): + if isinstance(node, dict): + service = node.get("aws_service") + if isinstance(service, str): + yield service + for value in node.values(): + yield from _iter_aws_services(value) + elif isinstance(node, list): + for item in node: + yield from _iter_aws_services(item) + + +def _design_has_clusters(aws_design: dict | None) -> bool: + if not aws_design: + return False + clusters = aws_design.get("clusters") + return isinstance(clusters, list) and len(clusters) > 0 + + +def _design_has_deferred_service(aws_design: dict | None) -> bool: + if not aws_design: + return False + return any( + service.strip().lower().startswith("deferred") + for service in _iter_aws_services(aws_design) + ) + + +def _what_if_column_errors(section: str) -> list[str]: + """Workshop compare tables must carry the decision-core column set.""" + header = re.search(r"", section, re.DOTALL | re.IGNORECASE) + if not header: + return [ + "what-if-scenarios must contain a table whose header includes " + "Scenario, Region, HA, Compute, Arch, the three monthly tiers, and Complexity" + ] + text = _plain_text(header.group(0)) + missing: list[str] = [] + for label, present in ( + ("Scenario", "scenario" in text), + ("Region", "region" in text), + ("HA", "ha" in text or "availability" in text), + ("Compute", "compute" in text), + ("Arch", "arch" in text), + ("Complexity", "complexity" in text), + ): + if not present: + missing.append(label) + if not ("premium" in text and "balanced" in text and "optimized" in text): + missing.append("Premium/Balanced/Optimized") + if not missing: + return [] + return [ + "what-if-scenarios table is missing decision-core column(s): " + + ", ".join(missing) + + " (report-decision-core.md Section 3b)" + ] + + +def _validate_decision_core_render( + html: str, + estimation_infra: dict | None, + aws_design: dict | None, + *, + mode: str, + migration_dir: Path | None, +) -> list[str]: + """Fail when artifact fields that the decision core requires are not rendered. + + REPORT_OK used to mean "the section IDs exist." A report can satisfy that + and still omit the verdict headline, hero metrics, flip conditions, the + specialist callout, and the architecture section. These checks fire only + when the corresponding artifact data was passed in, so a pre-extension + estimate is not rejected for fields it does not have. + """ + if mode not in ("full", "decision"): + return [] + errors: list[str] = [] + summary = _section_html(html, "decision-summary") or "" + summary_text = _plain_text(summary) + recommendation = (estimation_infra or {}).get("recommendation") or {} + + if recommendation: + if not re.search(r"\bmetric-hero\b", summary, re.IGNORECASE): + errors.append( + "recommendation block exists but decision-summary has no hero metric " + '(render the AWS run rate and migration shape with class="metric-hero")' + ) + outcome = recommendation.get("outcome") or recommendation.get("outcome_label") + if outcome and not re.search(r"\bverdict-headline\b", summary, re.IGNORECASE): + errors.append( + "recommendation.outcome exists but decision-summary has no " + 'verdict-headline (render outcome_label as

      )' + ) + + flips = recommendation.get("would_flip_if") + if isinstance(flips, list) and flips and "what would flip" not in summary_text: + errors.append( + "recommendation.would_flip_if is non-empty but decision-summary has no " + '"What would flip this" list' + ) + + tracks = recommendation.get("track_outcomes") + if isinstance(tracks, list) and tracks and "by track" not in summary_text: + errors.append( + "recommendation.track_outcomes exists but decision-summary has no " + '"By track" disposition line' + ) + + conditions = recommendation.get("conditions") + if ( + recommendation.get("outcome") == "conditional_go" + and isinstance(conditions, list) + and conditions + ): + summary_shingles = _word_shingles(summary_text) + for condition in conditions: + shingles = _word_shingles(_plain_text(str(condition))) + if shingles and not (shingles & summary_shingles): + errors.append( + "recommendation.conditions is non-empty but decision-summary does " + "not render that condition as a checklist (a shared phrase from the " + "artifact condition is missing)" + ) + break + + if _design_has_deferred_service(aws_design) and "specialist engagement" not in summary_text: + errors.append( + 'aws-design.json maps a service to "Deferred — specialist engagement" but ' + "decision-summary has no specialist-engagement callout" + ) + + if mode == "full" and _design_has_clusters(aws_design): + if _section_html(html, "exec-architecture") is None: + errors.append( + 'aws-design.json has clusters but no

      ' + "(the architecture overview is required in full mode)" + ) + + risks = re.search(r']*\bid=["\']exec-risks["\']', html, re.IGNORECASE) + assumptions = re.search( + r']*\bid=["\']exec-assumptions["\']', html, re.IGNORECASE + ) + if risks and assumptions and assumptions.start() < risks.start(): + errors.append( + "exec-assumptions must follow exec-risks — the assumptions panel is the " + "last executive section (report-decision-core.md Section 8)" + ) + + risks_html = _section_html(html, "exec-risks") + if risks_html is not None and not re.search(r"= 2 and section is not None: + errors.extend(_what_if_column_errors(section)) + + return errors + + def validate_report( html: str, estimation_infra: dict | None = None, @@ -1772,6 +1968,20 @@ def validate_report( ) ) + # Decision-core content. --no-require-toc remains the escape hatch for + # minimal unit fixtures; a normal Generate or Decision report must render + # the artifact fields, not only the section IDs. + if require_toc: + errors.extend( + _validate_decision_core_render( + html, + estimation_infra, + aws_design, + mode=mode, + migration_dir=migration_dir, + ) + ) + # Catch verbatim copies of the reference fixture into a real run. errors.extend(_validate_fixture_bleed(html, migration_dir)) diff --git a/plugins/aws-startup-advisor/skills/azure-to-aws/references/phases/generate/generate-artifacts-report.md b/plugins/aws-startup-advisor/skills/azure-to-aws/references/phases/generate/generate-artifacts-report.md index 69a72cf10..c302d9f17 100644 --- a/plugins/aws-startup-advisor/skills/azure-to-aws/references/phases/generate/generate-artifacts-report.md +++ b/plugins/aws-startup-advisor/skills/azure-to-aws/references/phases/generate/generate-artifacts-report.md @@ -183,9 +183,19 @@ migration plan, and that figures are estimates to validate before decisions. ## Step 5: Validate the rendered report (REQUIRED — mandatory gate) -The prose above is not self-enforcing. Run the plugin's report validator, which is -source-cloud-agnostic and already present at plugin root — resolve it the same way the -`tf-best-practices` policy checker is resolved: +The prose above is not self-enforcing. Render the report from +`references/shared/report-decision-core.md` in **full** mode (or AI-only mode +when there is no infra track) before you write HTML. `REPORT_OK` is not a +substitute for that file: the shared validator rejects a report that has the +right section IDs but omits decision-core content the artifacts require +(verdict headline, hero metrics, per-track line, conditions, "What would flip +this", the specialist-engagement callout, the architecture section when the +design has clusters, assumptions after risks, a risk table, and the what-if +columns Region / HA / Compute / Arch when two or more scenarios exist). + +Run the plugin's report validator, which is source-cloud-agnostic and already +present at plugin root — resolve it the same way the `tf-best-practices` policy +checker is resolved: ```bash python3 "$PLUGIN_ROOT/scripts/validate-migration-report.py" \ diff --git a/plugins/aws-startup-advisor/skills/azure-to-aws/references/phases/generate/generate.md b/plugins/aws-startup-advisor/skills/azure-to-aws/references/phases/generate/generate.md index 0d865e9ef..c9df53f93 100644 --- a/plugins/aws-startup-advisor/skills/azure-to-aws/references/phases/generate/generate.md +++ b/plugins/aws-startup-advisor/skills/azure-to-aws/references/phases/generate/generate.md @@ -60,7 +60,7 @@ _postconditions: _on_failure: _halt_and_inform - _assert: "WHEN the run has an infra track: terraform/main.tf has a valid provider configuration; terraform/variables.tf declares at least an aws_region variable; at least one domain .tf file exists beyond the core files; and MIGRATION_GUIDE.md has Prerequisites and Verification sections. WHEN AI-only: vacuously satisfied (no terraform/ or MIGRATION_GUIDE.md on this path). README.md lists the generated artifacts on every path." _on_failure: _halt_and_inform - - _assert: "migration-report.html ALWAYS exists (it is a required _produces artifact on every path — an infra/mixed run and an AI-only app-code run alike; a completed Generate with no report is a gate failure, never a silent skip). It was rendered from references/shared/report-decision-core.md and PASSES $PLUGIN_ROOT/scripts/validate-migration-report.py (REPORT_OK, exit 0), which is the authority for required section IDs, TOC integrity, the CSS readability contract, and accessibility. Infra/mixed runs render full mode (validated default mode); an AI-only run (no aws-design.json / estimation-infra.json) renders AI-only mode and is validated with --mode ai_only --estimation-ai estimation-ai.json. A REPORT_FAIL leaves migration-report.incomplete.html and blocks completion. Additionally, for a run with an infra track: the report leads with cluster-level architecture rationale, the per-resource mapping table appears only in an appendix, and a draft-for-review footer is present" + - _assert: "migration-report.html ALWAYS exists (it is a required _produces artifact on every path — an infra/mixed run and an AI-only app-code run alike; a completed Generate with no report is a gate failure, never a silent skip). It was rendered from references/shared/report-decision-core.md and PASSES $PLUGIN_ROOT/scripts/validate-migration-report.py (REPORT_OK, exit 0), which enforces required section IDs, TOC integrity, the CSS readability contract, accessibility, and the decision-core content checks (verdict headline, hero metrics, flip conditions, specialist callout, architecture section, what-if columns) when the artifacts contain that data. REPORT_OK does not replace rendering references/shared/report-decision-core.md. Infra/mixed runs render full mode (validated default mode); an AI-only run (no aws-design.json / estimation-infra.json) renders AI-only mode and is validated with --mode ai_only --estimation-ai estimation-ai.json. A REPORT_FAIL leaves migration-report.incomplete.html and blocks completion. Additionally, for a run with an infra track: the report leads with cluster-level architecture rationale, the per-resource mapping table appears only in an appendix, and a draft-for-review footer is present" _on_failure: _halt_and_inform - _assert: "if scenarios/index.json has at least 2 scenarios, migration-report.html includes the what-if comparison" _on_failure: _halt_and_inform diff --git a/plugins/aws-startup-advisor/skills/gcp-to-aws/references/phases/generate/generate-artifacts-report.md b/plugins/aws-startup-advisor/skills/gcp-to-aws/references/phases/generate/generate-artifacts-report.md index e3fd70935..4b42c56ad 100644 --- a/plugins/aws-startup-advisor/skills/gcp-to-aws/references/phases/generate/generate-artifacts-report.md +++ b/plugins/aws-startup-advisor/skills/gcp-to-aws/references/phases/generate/generate-artifacts-report.md @@ -721,10 +721,20 @@ After generating the HTML file, verify: Load `shared/validate-migration-report.md`. Resolve script from plugin root: `$PLUGIN_ROOT/scripts/validate-migration-report.py`. -The validator is **stdlib Python only**. It is the sole quality gate for generated -reports. Do **not** ask the user to install Vale, Pa11y, axe, Lighthouse, -Chromium, or any other prose/accessibility tooling. Do **not** skip validation -because those tools are missing — they are never required. +The validator is **stdlib Python only**. Do **not** ask the user to install Vale, +Pa11y, axe, Lighthouse, Chromium, or any other prose/accessibility tooling. Do +**not** skip validation because those tools are missing — they are never required. + +`REPORT_OK` is not a substitute for this file or for +`references/shared/report-decision-core.md`. Load the decision core and render +it in **full** mode before writing HTML. The validator then rejects a report +that has the right section IDs but omits the decision-core content those +artifacts require: the verdict headline, hero metrics, the per-track line, the +conditions checklist, "What would flip this", the specialist-engagement +callout, the architecture section when the design has clusters, the +assumptions panel after risks, a risk table, and the what-if column set +(Region, HA, Compute, Arch) when two or more scenarios exist. A report written +only to satisfy an older structural gate will fail. ```bash python3 "$PLUGIN_ROOT/scripts/validate-migration-report.py" \ diff --git a/plugins/aws-startup-advisor/skills/gcp-to-aws/references/phases/generate/generate.md b/plugins/aws-startup-advisor/skills/gcp-to-aws/references/phases/generate/generate.md index adfe5870f..f1a8e7143 100644 --- a/plugins/aws-startup-advisor/skills/gcp-to-aws/references/phases/generate/generate.md +++ b/plugins/aws-startup-advisor/skills/gcp-to-aws/references/phases/generate/generate.md @@ -162,7 +162,7 @@ Produces: `migration-report.html` **Validation gate:** Report generation runs `shared/validate-artifacts.md` first. If validation emits `GATE_FAIL`: log the failure to the user, **do not write** `migration-report.html`, and continue to Phase Completion (report is optional output; validation failure is not a silent skip). Do **NOT** patch artifacts to pass validation. -After writing `migration-report.html`, run `shared/validate-migration-report.md` (automated script). Branch on the shell exit code per that doc's table — `0` (`REPORT_OK`), `1` (`REPORT_FAIL`: remove or rename incomplete HTML and warn the user — do not present a stub report as complete), or anything else (validator did not run, e.g. `python3` missing — tell the user validation was skipped, do not treat it as pass or fail). +After writing `migration-report.html`, run `shared/validate-migration-report.md` (automated script). `REPORT_OK` means the decision-core content checks passed, not that the section IDs alone were present — the HTML must still have been rendered from `references/shared/report-decision-core.md`. Branch on the shell exit code per that doc's table — `0` (`REPORT_OK`), `1` (`REPORT_FAIL`: remove or rename incomplete HTML and warn the user — do not present a stub report as complete), or anything else (validator did not run, e.g. `python3` missing — tell the user validation was skipped, do not treat it as pass or fail). ## Phase Completion diff --git a/plugins/aws-startup-advisor/skills/gcp-to-aws/references/shared/validate-migration-report.md b/plugins/aws-startup-advisor/skills/gcp-to-aws/references/shared/validate-migration-report.md index 15966cd1f..a2c8b8443 100644 --- a/plugins/aws-startup-advisor/skills/gcp-to-aws/references/shared/validate-migration-report.md +++ b/plugins/aws-startup-advisor/skills/gcp-to-aws/references/shared/validate-migration-report.md @@ -48,8 +48,13 @@ Handle exactly three outcomes: ## Scope -This validator is a **structural + content + visual-readability completeness -gate**. The visual contract is intentionally capability-based rather than +This validator is a **structural + decision-core + visual-readability completeness +gate**. `REPORT_OK` does not replace loading `report-decision-core.md` and +rendering it before the HTML is written. When the estimate or design passed +on the command line contains the data, the script also rejects a report that +omits the verdict headline, hero metrics, the per-track line, the conditions +checklist, "What would flip this", the specialist-engagement callout, the +architecture section, a risk table, or the what-if column set. The visual contract is intentionally capability-based rather than pixel-perfect: reports must preserve the shared contrasting page shell, bordered section cards, responsive metric grid, mobile breakpoint, keyboard focus state, and print rules from `generate-artifacts-report.md`. Colors and @@ -87,7 +92,7 @@ decision reports. | 14 | No fixture bleed | With `--migration-dir`: the reference canary migration ID does not appear in a real run, and the report's migration ID matches the run folder | | 15 | Readability — reader vocabulary | No artifact filename (`*.json`) or Terraform resource ID (`aws_.`) inside any `exec-*` / `decision-summary` section. The executive flow names what the reader controls; those identifiers live only in the appendices | | 16 | Ordered action lists | When `Key decisions ahead` or `Next steps` headings exist in `decision-summary`, the following list is `
        `, not `
          ` | -| 17 | Configuration provenance | When `
          ` exists: table includes Question/Assumption and Design consequence columns; ≥2 data rows | +| 17 | Configuration provenance | When `
          ` exists: table includes Question/Assumption, Your choice, Source, and Design consequence columns; ≥2 data rows | | 18 | What-if scenarios | With `--migration-dir`: if `scenarios/index.json` lists ≥2 scenarios, exactly one `
          ` (workshop compare table) | | 19 | Activate action link | If `decision-summary` mentions AWS Activate, that section includes a clickable link to `https://aws.amazon.com/startups/credits/`; an appendix-only link is insufficient | | 20 | Glossary appendix | Full reports contain `
          ` with a bordered ``, `Term` and `Meaning` columns, and at least three rows | @@ -96,6 +101,7 @@ decision reports. | 23 | Leadership brief | When an estimation artifact is passed, `exec-share` contains a static, copy-ready `share-card` without scripts or controls | | 24 | Accessibility semantics | Normal generated reports declare document language, contain exactly one `

          `, caption every table, scope table headers, and give every figure an image role, accessible name, and text alternative | | 25 | Cost Optimization section | If `optimization_opportunities[]` is non-empty: exactly one `exec-optimization` (TOC-linked) comparing Balanced on-demand vs Savings Plans / RI, plus (full mode only) a standalone `appendix-optimization` table with Optimization / Target / Monthly savings / Commitment / Effort columns. A table buried only in `appendix-costs` fails. Do not add SP/RI savings on top of Optimized. If RDS/Aurora/Fargate/Lambda is in `--aws-design` or opportunity targets, the section must include a Savings Plans or Reserved Instances row | +| 26 | Decision-core render | When `--estimation-infra` has a `recommendation`: hero metrics (`metric-hero`) and, when the fields exist, a `verdict-headline`, a "By track" line, a conditions checklist, and "What would flip this". When `--aws-design` has a deferred service: a specialist-engagement callout in `decision-summary`. Full mode with `clusters`: `
          `. `exec-assumptions` follows `exec-risks`. `exec-risks` is a table. With ≥2 scenarios, the what-if table includes Region, HA, Compute, Arch, and Complexity | Checks 10, 11, and 15 scan the `` with `