From 1e5108678ccd282b34cb3a28fb4bee3b64e2e9ae Mon Sep 17 00:00:00 2001 From: Greg Magolan Date: Sun, 21 Jun 2026 16:51:27 -0700 Subject: [PATCH 1/2] feat(status): add selectable template styles + DX-focused "Strata" style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI status surfaces now support a `style` arg selecting how results render. Two styles ship built-in: - kilgore (default) β€” the original full report (byte-identical to before). - strata β€” a DX-focused layout that answers, in order: what broke β†’ how to fix/repro it β†’ why it was slow. Leads with a verdict heading + a compact monospace KPI strip (cache hit %, parallelism, wall + critical-path share, each with a 🟒/🟑/πŸ”΄ threshold emoji standing in for the web UI's tile color), shows failure snippets open by default with each failure's repro command beneath it, and collapses the reference sections (recoverable via the Aspect Web UI). The performance block auto-expands on a "Passed, but slow" verdict. Mechanism (lib/template_styles.axl): a style is a bundle of the three top-level Jinja2 template strings (summary/details for the check surfaces, body for the aggregated PR/MR comment) plus render knobs. resolve_style(name, overrides) layers the existing per-key `templates` arg on top of the named style, so users pick a style and replace just one slot for a custom variant. The Strata strings live in lib/strata_templates.axl; both consume the same build_details_data / _render_body data shape (no data-layer fork), with a new compute_strata_kpi() adding the KPI strip inputs. Wired into all five status features: GitHub status checks, GitHub PR comment, GitLab commit statuses, GitLab MR comment, Buildkite annotation. Tests: lib/template_styles_test.axl (20 unit tests β€” resolve_style selection + override layering, KPI thresholds, verdict words, slow-but-passing detection, verdict-without-metrics); Strata render passes added to the template + PR-comment snapshot suites and the inline-snippet render test in .aspect/axl.axl. Kilgore output is unchanged. Verified live on this repo's own CI (PR comment + GitHub status checks, including the "Passed, but slow" auto-expand). Co-Authored-By: Claude Opus 4.8 (1M context) --- .aspect/axl.axl | 17 + .aspect/config.axl | 6 + .../aspect-cli/src/builtins/aspect/README.md | 1 + .../aspect/feature/buildkite_annotations.axl | 20 +- .../aspect/feature/github_status_checks.axl | 17 +- .../aspect/feature/github_status_comments.axl | 14 +- .../feature/github_status_comments_test.axl | 24 +- .../aspect/feature/gitlab_commit_statuses.axl | 13 +- .../aspect/feature/gitlab_status_comments.axl | 17 +- .../src/builtins/aspect/lib/bazel_results.axl | 174 +++++++++++ .../aspect/lib/bazel_results_test.axl | 31 +- .../builtins/aspect/lib/strata_templates.axl | 295 ++++++++++++++++++ .../builtins/aspect/lib/template_styles.axl | 145 +++++++++ .../aspect/lib/template_styles_test.axl | 189 +++++++++++ 14 files changed, 950 insertions(+), 13 deletions(-) create mode 100644 crates/aspect-cli/src/builtins/aspect/lib/strata_templates.axl create mode 100644 crates/aspect-cli/src/builtins/aspect/lib/template_styles.axl create mode 100644 crates/aspect-cli/src/builtins/aspect/lib/template_styles_test.axl diff --git a/.aspect/axl.axl b/.aspect/axl.axl index 329a4f427..ca575baf2 100644 --- a/.aspect/axl.axl +++ b/.aspect/axl.axl @@ -31,6 +31,7 @@ load("@aspect//lib/repro_commands.axl", "build_aspect_command", "dedup_and_attri load("@aspect//lib/result_text.axl", "severity_for_status") load("@aspect//lib/runnable.axl", "apparent_label", "runnable") load("@aspect//lib/sarif.axl", "get_sarif_summary", "parse_sarif", "parse_sarif_diagnostics", "sarif_to_review_comments") +load("@aspect//lib/template_styles.axl", "resolve_style") load("@aspect//lib/tips.axl", "tips") load("@aspect//lint.axl", "file_uri_to_path", "filter_all", "filter_changeset", "linter_error_message") load("@demo//answer.axl", "ANSWER") @@ -3378,6 +3379,22 @@ def test_bazel_render_snippets(ctx: TaskContext, tc: int, temp_dir: str) -> int: tc = test_case(tc, "…\nline 45" in out3["text"], "render: dropped-head truncation marker (leading …)") tc = test_case(tc, "line 49" in out3["text"], "render: windowed snippet keeps the tail") + # Strata style: same fixture `r` (snippets + per-failure repros), rendered + # through the Strata templates. The DX layout leads with a verdict heading + + # KPI strip, shows each failure snippet open with its repro beneath it, and + # keeps the whole-run command out of the combined Reproduce section. + _strata = resolve_style("strata", {}) + _st = {"summary": _strata["summary"], "details": _strata["details"]} + out_s = render_check_output(ctx, r, "failed", render_ctx, links, templates = _st, snippet_options = opts, max_snippets = max_snips) + tc = test_case(tc, "### ❌ Failed" in out_s["text"], "strata: verdict heading") + tc = test_case(tc, "### πŸ’₯ What broke" in out_s["text"], "strata: what-broke section") + tc = test_case(tc, "FAILED: expected 1 got 2" in out_s["text"], "strata: inlines failed-test snippet") + tc = test_case(tc, "no member named 'foo'" in out_s["text"], "strata: inlines failed-action snippet") + tc = test_case(tc, "aspect test -- //pkg:fail_test" in out_s["text"], "strata: per-failure aspect repro inline") + tc = test_case(tc, "aspect build -- //pkg:broken" in out_s["text"], "strata: per-failure action repro inline") + _s_repro = out_s["text"][out_s["text"].find("πŸ” Reproduce"):] if "πŸ” Reproduce" in out_s["text"] else "" + tc = test_case(tc, "//pkg:fail_test" not in _s_repro, "strata: combined Reproduce excludes per-failure commands") + ctx.std.fs.remove_dir_all(d) return tc diff --git a/.aspect/config.axl b/.aspect/config.axl index 85a678d99..a098f9041 100644 --- a/.aspect/config.axl +++ b/.aspect/config.axl @@ -32,6 +32,7 @@ load("@aspect//lib/runnable_test.axl", "runnable_tests") load("@aspect//lib/runner_job_history_test.axl", "runner_job_history_tests") load("@aspect//lib/sandbox_recovery_test.axl", "sandbox_recovery_tests") load("@aspect//lib/tar_test.axl", "tar_tests") +load("@aspect//lib/template_styles_test.axl", "template_styles_tests") load("@aspect//lib/tips_test.axl", "tips_tests") load("@aspect//lib/wrapper_test.axl", "wrapper_tests") load("@aspect//tips.axl", "TASK_SCREENS", "TIP_SUGGESTION", "TIP_TEMPLATE_RAW", "add_tip") @@ -272,6 +273,11 @@ def config(ctx: ConfigContext): # Run with: aspect dev test-tar ctx.tasks.add(tar_tests) + # Template-style machinery: resolve_style selection + override layering, + # compute_strata_kpi verdict/thresholds/slow-detection. + # Run with: aspect dev test-template-styles + ctx.tasks.add(template_styles_tests) + # CircleCI S3 error parsing: expired-credential 400 β†’ legible reason. # Run with: aspect dev test-circleci ctx.tasks.add(circleci_tests) diff --git a/crates/aspect-cli/src/builtins/aspect/README.md b/crates/aspect-cli/src/builtins/aspect/README.md index 747805458..41575f40b 100644 --- a/crates/aspect-cli/src/builtins/aspect/README.md +++ b/crates/aspect-cli/src/builtins/aspect/README.md @@ -218,5 +218,6 @@ Behavior notes: | [lib/format_results.axl](lib/format_results.axl) | `init_data`, `render_check_output`, `format_summary_title` | format | | [lib/gazelle_results.axl](lib/gazelle_results.axl) | `init_data`, `render_check_output`, `gazelle_summary_title` | gazelle | | [lib/delivery_results.axl](lib/delivery_results.axl) | `init_data`, `add_result`, `render_check_output`, `delivery_summary_title` | delivery | +| [lib/template_styles.axl](lib/template_styles.axl) | `resolve_style`, `builtin_style`, `STYLE_NAMES`, `STYLE_ARG_DESCRIPTION` (Strata strings in [lib/strata_templates.axl](lib/strata_templates.axl)) | every status feature (the `style` arg) | Each `*_results.axl` derives its `init_data()` from `bazel_results.init_data()` (so `process_event` can populate the full bazel state) and appends `SHARED_DETAILS_BODY_TEMPLATE` to its task-specific top section so the rendered details body has the same Targets / Build Metrics / Invocation / Workflows Runner / Workspace Status / Build Metadata / Options-parsed sections everywhere. diff --git a/crates/aspect-cli/src/builtins/aspect/feature/buildkite_annotations.axl b/crates/aspect-cli/src/builtins/aspect/feature/buildkite_annotations.axl index 78431652f..591f0c883 100644 --- a/crates/aspect-cli/src/builtins/aspect/feature/buildkite_annotations.axl +++ b/crates/aspect-cli/src/builtins/aspect/feature/buildkite_annotations.axl @@ -34,6 +34,7 @@ load("../lib/environment.axl", "feature_logger", "workflows_results_url") load("../lib/lifecycle.axl", "TaskLifecycleTrait", "TaskUpdate") load("../lib/rate_limit.axl", "usage_footer") load("../lib/result_text.axl", "severity_for_status") +load("../lib/template_styles.axl", "STYLE_ARG_DESCRIPTION", "STYLE_NAMES", "resolve_style") load("../lib/tips.axl", "SURFACE_BUILDKITE_ANNOTATIONS", "collect_tips_sorted") # ─── Style selection ────────────────────────────────────────────────────────── @@ -205,6 +206,10 @@ def _buildkite_annotations(ctx: FeatureContext): mode = ctx.args.mode is_bk = bool(ctx.std.env.var("BUILDKITE")) + # Resolve the named style into the {summary, details} the renderer consumes. + _style = resolve_style(ctx.args.style, ctx.args.templates or {}) + _templates = {"summary": _style["summary"], "details": _style["details"]} + _LOG.trace("starting (mode=%s BUILDKITE=%r is_bk=%s)" % (mode, ctx.std.env.var("BUILDKITE") or "", is_bk)) @@ -322,7 +327,7 @@ def _buildkite_annotations(ctx: FeatureContext): status, make_render_ctx(_state, tips = collect_tips_sorted(ctx, surface = SURFACE_BUILDKITE_ANNOTATIONS), api_usage = usage_footer(ctx), last_updated_at = format_run_date(ctx, now_ms(ctx))), _make_links(data), - None, + _templates, None, snippet_options = _snippet_opts, max_snippets = _max_snippets, @@ -357,5 +362,18 @@ BuildkiteAnnotations = feature( "less-responsive live annotations. The task's terminal result and phase " + "boundaries always land immediately regardless of this setting.", ), + "style": args.string( + default = "kilgore", + values = STYLE_NAMES, + description = STYLE_ARG_DESCRIPTION, + ), + "templates": args.custom( + dict, + default = {}, + description = "Map of template overrides for the rendered annotation body. " + + "Keys: \"summary\", \"details\". Each value is a Jinja2 template string. " + + "Empty dict (default) uses the selected `style`'s templates. Overrides " + + "layer on top of `style`.", + ), }, ) diff --git a/crates/aspect-cli/src/builtins/aspect/feature/github_status_checks.axl b/crates/aspect-cli/src/builtins/aspect/feature/github_status_checks.axl index fc087c179..a335179a6 100644 --- a/crates/aspect-cli/src/builtins/aspect/feature/github_status_checks.axl +++ b/crates/aspect-cli/src/builtins/aspect/feature/github_status_checks.axl @@ -56,6 +56,7 @@ load( "should_emit_phase_change", "usage_footer", ) +load("../lib/template_styles.axl", "STYLE_ARG_DESCRIPTION", "STYLE_NAMES", "resolve_style") load("../lib/tips.axl", "SURFACE_GITHUB_STATUS_CHECKS", "collect_tips_sorted") load("../lint.axl", "LintTrait") @@ -93,7 +94,12 @@ def _collect_ci_links(ctx, results_base_url): def _github_status_checks(ctx: FeatureContext): lifecycle = ctx.traits[TaskLifecycleTrait] - templates = ctx.args.templates or {} + # Resolve the named style (kilgore default) into its template bundle, then + # layer the per-key `templates` overrides on top (a custom variant). The + # resolved {summary, details} pair is what the renderer consumes; selecting + # the default style is byte-identical to the pre-style behavior. + _style = resolve_style(ctx.args.style, ctx.args.templates or {}) + templates = {"summary": _style["summary"], "details": _style["details"]} metadata_keys = list(ctx.args.metadata_keys or []) # Check-run URL is published below via `checkrun.set_url`; siblings @@ -490,12 +496,19 @@ GithubStatusChecks = feature( default = True, description = "Restrict lint check-run annotations to the displayed hunk regions of changed files (added lines + surrounding context from `detect_changed_files`). When false, off-region annotations are still posted but GitHub only renders them on the check-run detail page and commit page, not in Files Changed. Skipped silently when no changed-files context is available.", ), + "style": args.string( + default = "kilgore", + values = STYLE_NAMES, + description = STYLE_ARG_DESCRIPTION, + ), "templates": args.custom( dict, default = {}, description = "Map of template overrides for the rendered check-run output. " + "Keys: \"summary\", \"details\". Each value is a Jinja2 template " + - "string. Empty dict (default) uses the feature's built-in templates.", + "string. Empty dict (default) uses the selected `style`'s templates. " + + "Overrides layer on top of `style`, so you can pick a style and replace " + + "just one slot.", ), "metadata_keys": args.string_list( default = [], diff --git a/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments.axl b/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments.axl index 866a70025..ce7e42451 100644 --- a/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments.axl +++ b/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments.axl @@ -82,6 +82,7 @@ load( load("../lib/repro_commands.axl", "ASPECT_CLI_INSTALL_HINT_MARKDOWN", "dedup_and_attribute", "rehydrate_commands") load("../lib/result_text.axl", "severity_for_status") load("../lib/tar.axl", "bsdtar") +load("../lib/template_styles.axl", "STYLE_ARG_DESCRIPTION", "STYLE_NAMES", "resolve_style") load( "../lib/tips.axl", "SURFACE_GITHUB_PR_SUMMARY", @@ -1264,9 +1265,13 @@ def _github_status_comments(ctx: FeatureContext): min_poll_interval_seconds = ctx.args.min_poll_interval_seconds artifact_expires_minutes = ctx.args.artifact_expires_minutes + # Resolve the named style (kilgore default), then layer the per-key + # `templates` overrides. The Kilgore bundle carries no `body` (the body + # template lives in this feature), so it falls back to _DEFAULT_BODY_TEMPLATE; + # Strata supplies its own body; a `templates["body"]` override wins over both. # args.custom(dict, ...) hands None at runtime, not the declared default. - templates = ctx.args.templates or {} - body_template = templates.get("body") or _DEFAULT_BODY_TEMPLATE + _style = resolve_style(ctx.args.style, ctx.args.templates or {}) + body_template = _style["body"] or _DEFAULT_BODY_TEMPLATE status_badges = dict(_DEFAULT_STATUS_BADGES) status_badges.update(ctx.args.status_badges or {}) @@ -1988,6 +1993,11 @@ GithubStatusComments = feature( "Set to 0 to disable (no expiry).", ), # Config-only (set via configure(..., feature_args = {...}) in config.axl). + "style": args.string( + default = "kilgore", + values = STYLE_NAMES, + description = STYLE_ARG_DESCRIPTION, + ), "templates": args.custom( dict, default = {}, diff --git a/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments_test.axl b/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments_test.axl index 89dc26698..af86ac76a 100644 --- a/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments_test.axl +++ b/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments_test.axl @@ -72,6 +72,7 @@ load( load("../lib/bazel_results.axl", "compute_reproducer_command") load("../lib/github.axl", "github") load("../lib/repro_commands.axl", "rehydrate_commands") +load("../lib/template_styles.axl", "resolve_style") load("../lib/tips.axl", "tips") def _eq(label, got, want): @@ -2332,8 +2333,29 @@ def _test_impl(ctx): print(body) print("") + # Strata style: render a subset through the Strata body template so any + # Jinja/data-shape regression in the aggregated comment surfaces here. + strata = resolve_style("strata", {}) + for (label, raw_entries) in scenarios: + prepared = prepare_for_render(raw_entries) + sections = bucket_entries(prepared) + body = render_body( + ctx, + MARKER_FMT % 1234, + prepared, + "Fri May 10 07:16:05 UTC 2024", + strata["body"], + DEFAULT_STATUS_BADGES, + sections, + ) + print(sep) + print("SCENARIO: strata Β· " + label) + print(sep) + print(body) + print("") + print(sep) - print("All %d PR-comment scenarios rendered without error." % len(scenarios)) + print("All %d PR-comment scenarios rendered without error (Kilgore + Strata)." % len(scenarios)) return 0 def _aborted(data, reason): diff --git a/crates/aspect-cli/src/builtins/aspect/feature/gitlab_commit_statuses.axl b/crates/aspect-cli/src/builtins/aspect/feature/gitlab_commit_statuses.axl index da5e111c0..0cb9b8daf 100644 --- a/crates/aspect-cli/src/builtins/aspect/feature/gitlab_commit_statuses.axl +++ b/crates/aspect-cli/src/builtins/aspect/feature/gitlab_commit_statuses.axl @@ -87,6 +87,7 @@ load( "effective_throttle_factor", "should_emit_phase_change", ) +load("../lib/template_styles.axl", "STYLE_ARG_DESCRIPTION", "STYLE_NAMES", "resolve_style") _LOG = feature_logger("GitLab commit statuses") @@ -152,7 +153,9 @@ def _state_for(status, final): def _gitlab_commit_statuses(ctx: FeatureContext): lifecycle = ctx.traits[TaskLifecycleTrait] - templates = ctx.args.templates or {} + # Resolve the named style (kilgore default) + per-key `templates` overrides. + _style = resolve_style(ctx.args.style, ctx.args.templates or {}) + templates = {"summary": _style["summary"], "details": _style["details"]} metadata_keys = list(ctx.args.metadata_keys or []) mode = ctx.args.mode @@ -428,12 +431,18 @@ GitlabCommitStatuses = feature( "or the task runs longer. The task's terminal result always lands immediately " + "so the final state shows up without delay.", ), + "style": args.string( + default = "kilgore", + values = STYLE_NAMES, + description = STYLE_ARG_DESCRIPTION, + ), "templates": args.custom( dict, default = {}, description = "Map of template overrides for the rendered status output. " + "Keys: \"summary\", \"details\". Each value is a Jinja2 template " + - "string. Empty dict (default) uses the feature's built-in templates. " + + "string. Empty dict (default) uses the selected `style`'s templates; " + + "overrides layer on top of `style`. " + "Note: only the rendered `title` lands on the commit status (in " + "`description`, truncated to 255 chars) β€” the full body is rendered " + "into the GitlabStatusComments rolling note.", diff --git a/crates/aspect-cli/src/builtins/aspect/feature/gitlab_status_comments.axl b/crates/aspect-cli/src/builtins/aspect/feature/gitlab_status_comments.axl index 260599f40..96fa95e24 100644 --- a/crates/aspect-cli/src/builtins/aspect/feature/gitlab_status_comments.axl +++ b/crates/aspect-cli/src/builtins/aspect/feature/gitlab_status_comments.axl @@ -76,6 +76,7 @@ load( "epoch_now_s", "usage_footer", ) +load("../lib/template_styles.axl", "STYLE_ARG_DESCRIPTION", "STYLE_NAMES", "resolve_style") _LOG = feature_logger("GitLab status comments") @@ -153,8 +154,11 @@ def _gitlab_status_comments(ctx: FeatureContext): ci_run_url = _ci_run_url(env) min_poll_interval_seconds = max(1, ctx.args.min_poll_interval_seconds) - templates = ctx.args.templates or {} - body_template = templates.get("body") or DEFAULT_BODY_TEMPLATE + # Resolve the named style, then layer per-key `templates` overrides. Kilgore + # carries no body (falls back to the shared GitHub body template); Strata + # supplies its own; a `templates["body"]` override wins over both. + _style = resolve_style(ctx.args.style, ctx.args.templates or {}) + body_template = _style["body"] or DEFAULT_BODY_TEMPLATE status_badges = dict(DEFAULT_STATUS_BADGES) status_badges.update(ctx.args.status_badges or {}) @@ -442,13 +446,18 @@ GitlabStatusComments = feature( "the cost stays flat regardless of how many run in parallel. Terminal task " + "verdicts always land immediately.", ), + "style": args.string( + default = "kilgore", + values = STYLE_NAMES, + description = STYLE_ARG_DESCRIPTION, + ), "templates": args.custom( dict, default = {}, description = "Per-section Jinja2 template overrides. Keys: 'body'. " + "Vars available match the GithubStatusComments template contract " + - "(see feature/github_status_comments.axl). Empty dict β†’ built-in default. " + - "config.axl only.", + "(see feature/github_status_comments.axl). Empty dict β†’ selected `style`'s " + + "body. Overrides layer on top of `style`. config.axl only.", ), "status_badges": args.custom( dict, diff --git a/crates/aspect-cli/src/builtins/aspect/lib/bazel_results.axl b/crates/aspect-cli/src/builtins/aspect/lib/bazel_results.axl index 4c906dea7..c83827eb6 100644 --- a/crates/aspect-cli/src/builtins/aspect/lib/bazel_results.axl +++ b/crates/aspect-cli/src/builtins/aspect/lib/bazel_results.axl @@ -3465,6 +3465,177 @@ def _build_invocation_stats(data): "miss_reasons_table": miss_reasons_pre, } +# Strata KPI thresholds: (green-floor, amber-floor). A value β‰₯ green-floor is +# 🟒, β‰₯ amber-floor is 🟑, else πŸ”΄. Echo the Strata web UI's tile colors, which +# markdown can't set directly. `_kpi_emoji` maps a value through these. +_KPI_CACHE_GREEN = 85 +_KPI_CACHE_AMBER = 60 +_KPI_PARALLEL_GREEN = 4.0 +_KPI_PARALLEL_AMBER = 1.5 + +# Critical-path / analysis SHARE-of-wall thresholds are inverted (lower is +# better): a share at/under green-ceiling is 🟒, at/under amber-ceiling is 🟑. +_KPI_CRIT_SHARE_GREEN = 40 +_KPI_CRIT_SHARE_AMBER = 60 + +def _kpi_emoji_high(value, green_floor, amber_floor): + """🟒/🟑/πŸ”΄ for a higher-is-better KPI (cache hit %, parallelism).""" + if value >= green_floor: + return "🟒" + if value >= amber_floor: + return "🟑" + return "πŸ”΄" + +def _kpi_emoji_low(value, green_ceiling, amber_ceiling): + """🟒/🟑/πŸ”΄ for a lower-is-better KPI (critical-path share of wall).""" + if value <= green_ceiling: + return "🟒" + if value <= amber_ceiling: + return "🟑" + return "πŸ”΄" + +def compute_strata_kpi(data, status): + """Compute the Strata KPI strip: a verdict line + the at-a-glance health + numbers (cache hit %, parallelism, wall, critical-path share), each paired + with a 🟒/🟑/πŸ”΄ threshold emoji that stands in for the web UI's tile color. + + Returns a dict the Strata `details`/`body` templates read. Before any + build_metrics arrive it carries only the verdict (the heading still renders) + with an empty `strip_lines`, so the fenced KPI block is skipped. Shape: + + { + "verdict_emoji": "πŸ”΄", "verdict_word": "Failed", + "slow": bool, # green-but-slow β†’ auto-expand perf + "cache": {"emoji","value","sub"} | None, + "parallelism": {...} | None, + "wall": {"emoji","value","sub"} | None, + "strip_lines": [str, ...], # pre-aligned monospace KPI lines + } + + `slow` fires when wall is the dominant cost AND there's a legible reason β€” + a low cache hit or low parallelism β€” so the perf section always has + something to point at when it auto-expands. Pure-numeric inputs come from + `data["bazel"]`; thresholds are the `_KPI_*` constants. + """ + b = data["bazel"] + wall_ms = b.get("wall_time_ms", 0) or 0 + cpu_ms = b.get("cpu_time_ms", 0) or 0 + critical_ms = b.get("critical_path_ms", 0) or 0 + + executed, cached, total = _effective_action_cache_counts(data) + has_metrics = wall_ms > 0 or total > 0 + if not has_metrics: + # No build_metrics yet β€” surface the verdict heading anyway (computable + # from status), with an empty strip so the fenced KPI block is skipped. + v_emoji, v_word = _strata_verdict(data, status, False) + return { + "verdict_emoji": v_emoji, + "verdict_word": v_word, + "slow": False, + "cache": None, + "parallelism": None, + "wall": None, + "strip_lines": [], + } + + cache = None + if total > 0: + pct = (cached * 100) // total + cache = { + "emoji": _kpi_emoji_high(pct, _KPI_CACHE_GREEN, _KPI_CACHE_AMBER), + "value": str(pct) + "%", + "sub": format_int_comma(cached) + " / " + format_int_comma(total) + " actions", + } + + parallelism = None + if wall_ms > 0 and cpu_ms > 0: + ratio = float(cpu_ms) / float(wall_ms) + + # One decimal, e.g. "6.2Γ—". + tenths = int(ratio * 10 + 0.5) + parallelism = { + "emoji": _kpi_emoji_high(ratio, _KPI_PARALLEL_GREEN, _KPI_PARALLEL_AMBER), + "value": str(tenths // 10) + "." + str(tenths % 10) + "Γ—", + "sub": "cpu " + format_duration_ms(cpu_ms), + } + + wall = None + if wall_ms > 0: + crit_sub = "" + wall_emoji = "🟒" + if critical_ms > 0: + share = (critical_ms * 100) // wall_ms + wall_emoji = _kpi_emoji_low(share, _KPI_CRIT_SHARE_GREEN, _KPI_CRIT_SHARE_AMBER) + crit_sub = "crit path " + format_duration_ms(critical_ms) + " (" + str(share) + "%)" + wall = { + "emoji": wall_emoji, + "value": format_duration_ms(wall_ms), + "sub": crit_sub, + } + + # Green-but-slow: terminal pass where the build's cost is dominated by a + # legible bottleneck (poor cache reuse or poor parallelism). The verdict + # then reads "Passed, but slow" and the perf section auto-expands. + cache_red = cache != None and cache["emoji"] == "πŸ”΄" + parallel_red = parallelism != None and parallelism["emoji"] == "πŸ”΄" + is_pass = status == "passed" + slow = is_pass and (cache_red or parallel_red) + + verdict_emoji, verdict_word = _strata_verdict(data, status, slow) + + # Pre-align the strip as one line per KPI: "