steppy-dev 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,163 @@
1
+ """Prove a proposed rewrite before it is written to disk.
2
+
3
+ A codemod that silently changes what a test does is worse than no codemod: the
4
+ scenario still *looks* complete, and the coverage it lost is invisible. So every
5
+ rewrite is checked, and anything that fails is reported rather than applied.
6
+
7
+ **What is NOT checked, and why.** The first design compared the two sides
8
+ textually. That is the wrong bar: ``page.click(sel)`` and
9
+ ``page.locator(sel).click()`` are the same operation spelled two ways, and every
10
+ declarative step re-spells the call it replaces. Worse, the rewrites most worth
11
+ making are the ones that deliberately differ — a bare ``.check(force=True)``
12
+ becomes an *idempotent* ``toggle`` that will not untick an already-ticked box on
13
+ a re-run. A textual bar rejects exactly those.
14
+
15
+ **What is checked**, on every block:
16
+
17
+ 1. the proposed steps build real ``Step`` models and generate parseable Python
18
+ (the render itself proves the first, ``ast.parse`` the second);
19
+ 2. **no selector is lost** — every string literal the original named is still
20
+ named, compared as values so ``'[data-testid=\\"row\\"]'`` and
21
+ ``'[data-testid="row"]'`` count as one;
22
+ 3. **the operations are conserved** — every Playwright call the original made
23
+ still happens, up to the substitution table below, which is the complete list
24
+ of places a rewrite is allowed to change behaviour and says why for each.
25
+
26
+ That is a real bar and it catches the failures that matter: a rule that drops an
27
+ argument, mangles a timeout into the wrong slot, or quietly turns two operations
28
+ into one. It is not a proof of equivalence, and it is not a substitute for
29
+ reading the diff — which is why ``--dry-run`` prints one.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import ast
35
+ import re
36
+ from dataclasses import dataclass, field
37
+
38
+ __all__ = ["SUBSTITUTIONS", "VerificationResult", "verify_rewrite"]
39
+
40
+ #: Where a rewrite may legitimately change the emitted operations, and why.
41
+ #: ``original method -> (methods that may stand for it, reason)``.
42
+ SUBSTITUTIONS: dict[str, tuple[frozenset[str], str]] = {
43
+ "check": (frozenset({"check", "is_checked"}), "toggle is idempotent: a re-run cannot untick an already-ticked box"),
44
+ "uncheck": (frozenset({"uncheck", "is_checked"}), "toggle is idempotent"),
45
+ "wait_for_timeout": (
46
+ frozenset({"sleep"}),
47
+ "a hard sleep becomes `wait`'s time.sleep, or folds into the previous step's wait_after",
48
+ ),
49
+ "wait_for": (frozenset({"wait_for", "wait_for_selector"}), "the locator and page forms are interchangeable"),
50
+ "wait_for_selector": (frozenset({"wait_for", "wait_for_selector"}), "as above"),
51
+ "wait_for_load_state": (
52
+ frozenset({"wait_for_load_state"}),
53
+ "folds into the preceding navigate's `wait:`, which emits the same call",
54
+ ),
55
+ "is_visible": (frozenset({"to_be_visible", "expect"}), "an assert becomes Playwright's auto-waiting expect()"),
56
+ "count": (frozenset({"count", "to_have_count", "expect"}), "as above"),
57
+ }
58
+
59
+ #: Calls that only *build* a locator. They carry no behaviour, so a rewrite that
60
+ #: adds or drops one has changed nothing.
61
+ _PLUMBING = frozenset(
62
+ {"locator", "first", "last", "nth", "_interp", "_interp_val", "print", "strip", "format", "get_attribute"}
63
+ )
64
+
65
+ #: A helper call in the original becomes `call_helper` in the rewrite. The
66
+ #: helper's own name is not a Playwright operation, so it is matched by name.
67
+ _CALL_HELPER = "call_helper"
68
+
69
+ _STRING = re.compile(r"'(?:[^'\\]|\\.)*'" + r'|"(?:[^"\\]|\\.)*"')
70
+
71
+
72
+ @dataclass
73
+ class VerificationResult:
74
+ ok: bool
75
+ detail: str = ""
76
+ substitutions: list[str] = field(default_factory=list)
77
+
78
+
79
+ def _literal_values(code: str) -> set[str]:
80
+ """Every string literal in ``code``, as its *value*.
81
+
82
+ Collected by parsing, not by scanning. A regex over the source cannot see
83
+ where a string begins: the generated file opens with a triple-quoted
84
+ docstring, and a naive scanner reads ``\"\"\"`` as an empty string followed by
85
+ a real one, then mis-tokenises everything after it — which showed up as
86
+ "the rewrite would drop: , visible", a fragment of a call that was in fact
87
+ still there.
88
+
89
+ Values, not source: a ``custom`` body writes ``'[data-testid=\\"row\\"]'``
90
+ where a step writes ``'[data-testid="row"]'``, and those name one selector.
91
+ """
92
+ try:
93
+ tree = ast.parse(code)
94
+ except SyntaxError:
95
+ # A `custom` body is not always a parseable module on its own; fall back
96
+ # to the scanner, which is good enough for the fragments it can see.
97
+ return {m.group(0)[1:-1] for m in _STRING.finditer(code)}
98
+ return {node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value, str)}
99
+
100
+
101
+ def _methods(code: str) -> set[str]:
102
+ """Every called name in ``code``, excluding pure locator plumbing.
103
+
104
+ Parsed, not grepped: a method name inside a string or a comment is not a
105
+ call, and the whole point is to compare what actually runs.
106
+ """
107
+ try:
108
+ tree = ast.parse(code)
109
+ except SyntaxError:
110
+ return set()
111
+ names = set()
112
+ for node in ast.walk(tree):
113
+ if not isinstance(node, ast.Call):
114
+ continue
115
+ func = node.func
116
+ if isinstance(func, ast.Attribute):
117
+ names.add(func.attr)
118
+ elif isinstance(func, ast.Name):
119
+ names.add(func.id)
120
+ return names - _PLUMBING
121
+
122
+
123
+ def _without_preamble(code: str) -> str:
124
+ """The original minus the import machinery a rewrite deliberately deletes.
125
+
126
+ ``sys.path.insert`` and the helper import are not operations the test
127
+ performs; they are the scaffolding that let it reach a helper at all, and
128
+ removing them is the point of the ``call`` action.
129
+ """
130
+ from .rules import _IMPORT, _NOISE, split_statements
131
+
132
+ kept = [s for s in split_statements(code) if not _NOISE.match(s) and not _IMPORT.match(s)]
133
+ return "\n".join(kept)
134
+
135
+
136
+ def verify_rewrite(original_code: str, generated: str, steps: list[dict]) -> VerificationResult:
137
+ """Check a proposed rewrite against the ``custom`` block it replaces."""
138
+ try:
139
+ ast.parse(generated)
140
+ except SyntaxError as exc:
141
+ return VerificationResult(False, f"generated code does not parse: {exc}")
142
+
143
+ lost = _literal_values(original_code) - _literal_values(generated)
144
+ # The `sys.path` preamble names two paths that exist only to make the import
145
+ # work; the rewrite removes the import, so those literals go with it.
146
+ lost = {s for s in lost if s not in (".", "/steppy/project")}
147
+ if lost:
148
+ return VerificationResult(False, "the rewrite would drop: " + ", ".join(sorted(lost)[:3]))
149
+
150
+ before, after = _methods(_without_preamble(original_code)), _methods(generated)
151
+ # A `call` step routes the helper through `call_helper`, which is the whole
152
+ # point: the helper name moves from a call to a string argument.
153
+ if _CALL_HELPER in after:
154
+ after = after | {s.get("fn", "").rpartition(".")[2] for s in steps if s.get("action") == "call"}
155
+ missing = before - after
156
+ used: list[str] = []
157
+ for method in sorted(missing):
158
+ allowed, reason = SUBSTITUTIONS.get(method, (frozenset(), ""))
159
+ if allowed & after or (method == "wait_for_timeout" and any("wait_after" in s for s in steps)):
160
+ used.append(f"{method}: {reason}")
161
+ continue
162
+ return VerificationResult(False, f"the rewrite would stop calling {method}()")
163
+ return VerificationResult(True, substitutions=used)
steppy/migrate_cli.py ADDED
@@ -0,0 +1,152 @@
1
+ """``steppy migrate custom`` — rewrite mechanical custom blocks as real steps.
2
+
3
+ A leaf CLI module, registered via ``register_migrate(app)`` like
4
+ ``register_flaky``. Everything under :mod:`steppy.migrate` is imported *inside*
5
+ the command body, so ``import steppy.cli`` stays free of it
6
+ (``tests/test_no_migrate_import_on_default_path.py``).
7
+
8
+ The command is deliberately unexciting: it shows you a diff, tells you what it
9
+ would not touch and why, and only writes when you drop ``--dry-run``. The
10
+ residue it reports is the useful part — it is the input to the next round of
11
+ commands.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import difflib
17
+ import json as _json
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ import typer
22
+ from rich.markup import escape
23
+ from rich.table import Table
24
+
25
+ from ._cli_common import _cli_errors, _load_config_or_exit, console
26
+
27
+ migrate_app = typer.Typer(help="Rewrite mechanical `action: custom` blocks as declarative steps.")
28
+
29
+
30
+ def register_migrate(app: typer.Typer) -> None:
31
+ app.add_typer(migrate_app, name="migrate")
32
+
33
+
34
+ @migrate_app.command("custom")
35
+ def migrate_custom(
36
+ scenarios_dir: Path = typer.Option(Path("scenarios"), "--scenarios-dir", help="Where the scenario files live."),
37
+ scenario: Optional[str] = typer.Option(None, "--scenario", help="Only this scenario (by file stem)."),
38
+ dry_run: bool = typer.Option(True, "--dry-run/--write", help="Preview (default) or rewrite the files."),
39
+ show_diff: bool = typer.Option(False, "--diff", help="Print the rewrite for every converted block."),
40
+ show_skipped: bool = typer.Option(False, "--skipped", help="List every block left alone, with the reason."),
41
+ report: Optional[Path] = typer.Option(None, "--report", help="Write the full result as JSON."),
42
+ ) -> None:
43
+ """Convert the ``custom`` blocks that are pure Playwright plumbing.
44
+
45
+ A block is converted only when EVERY statement in it maps to a rule, and
46
+ only when the rewrite passes verification: the generated code parses, no
47
+ selector is lost, and every operation is conserved. Everything else is left
48
+ exactly as it was and reported.
49
+ """
50
+ with _cli_errors():
51
+ from .generator import SteppyGenerator
52
+ from .migrate import apply_plan, plan_scenarios
53
+
54
+ if not scenarios_dir.is_dir():
55
+ console.print(f"[red]No scenarios directory at {scenarios_dir}[/red]")
56
+ raise typer.Exit(code=1)
57
+
58
+ config = _load_config_or_exit()
59
+ generator = SteppyGenerator(config)
60
+
61
+ plans = plan_scenarios(scenarios_dir, only=scenario)
62
+ if not plans:
63
+ console.print("[green]No `action: custom` blocks found — nothing to migrate.[/green]")
64
+ return
65
+
66
+ results = []
67
+ converted = skipped = 0
68
+ steps_before = steps_after = 0
69
+ for plan in plans:
70
+ outcomes = apply_plan(plan, generator, write=not dry_run)
71
+ for outcome in outcomes:
72
+ verification = outcome.verification
73
+ if outcome.applied:
74
+ converted += 1
75
+ steps_before += 1
76
+ steps_after += len(outcome.step.steps or [])
77
+ else:
78
+ skipped += 1
79
+ results.append(
80
+ {
81
+ "file": plan.path.name,
82
+ "line": outcome.step.line,
83
+ "heading": outcome.step.heading.strip(),
84
+ "applied": outcome.applied,
85
+ "reason": (verification.detail if verification else outcome.step.reason)
86
+ or "; ".join(verification.substitutions if verification else []),
87
+ "steps": len(outcome.step.steps or []),
88
+ }
89
+ )
90
+ if show_diff and outcome.applied:
91
+ _print_block_diff(plan.path.name, outcome)
92
+
93
+ _print_summary(converted, skipped, steps_before, steps_after, dry_run)
94
+ if show_skipped:
95
+ _print_skipped(results)
96
+ if report:
97
+ report.write_text(_json.dumps(results, indent=2), encoding="utf-8")
98
+ console.print(f"[dim]report: {report}[/dim]")
99
+
100
+
101
+ def _print_block_diff(filename: str, outcome) -> None:
102
+ original = ["- action: custom", "- code: |"] + [" " + line for line in outcome.step.code.splitlines()]
103
+ rewritten = [line for line in outcome.markdown.splitlines() if not line.startswith("###")]
104
+ console.print(f"\n[bold]{filename}:{outcome.step.line}[/bold] {outcome.step.heading.strip()}")
105
+ for line in difflib.unified_diff(original, rewritten, lineterm="", n=99):
106
+ if line.startswith(("---", "+++", "@@")):
107
+ continue
108
+ style = "red" if line.startswith("-") else "green" if line.startswith("+") else "dim"
109
+ console.print(f" [{style}]{escape(line)}[/{style}]")
110
+
111
+
112
+ def _print_summary(converted: int, skipped: int, before: int, after: int, dry_run: bool) -> None:
113
+ total = converted + skipped
114
+ verb = "would convert" if dry_run else "converted"
115
+ console.print()
116
+ table = Table(show_header=True, header_style="bold")
117
+ table.add_column("custom blocks", justify="right")
118
+ table.add_column(verb, justify="right")
119
+ table.add_column("left alone", justify="right")
120
+ table.add_column("steps produced", justify="right")
121
+ table.add_row(
122
+ str(total),
123
+ f"[green]{converted}[/green]" + (f" ({converted / total:.0%})" if total else ""),
124
+ str(skipped),
125
+ f"{after} from {before}" + (f" ({after / before:.2f}×)" if before else ""),
126
+ )
127
+ console.print(table)
128
+ if after > before:
129
+ # Said out loud because the collapse metric counts blocks removed and is
130
+ # silent about steps added — and in a guide scenario every added step is
131
+ # a published step with its own number and screenshot slot.
132
+ console.print(
133
+ f"[yellow]Note:[/yellow] {after - before} more steps than blocks. A block holding two "
134
+ "operations becomes two steps; trailing sleeps are folded into wait_after: rather than "
135
+ "becoming steps of their own."
136
+ )
137
+ if dry_run:
138
+ console.print("[dim]Nothing was written. Re-run with --write to apply.[/dim]")
139
+
140
+
141
+ def _print_skipped(results: list[dict]) -> None:
142
+ reasons: dict[str, int] = {}
143
+ for row in results:
144
+ if row["applied"]:
145
+ continue
146
+ reasons[row["reason"] or "unknown"] = reasons.get(row["reason"] or "unknown", 0) + 1
147
+ if not reasons:
148
+ return
149
+ console.print("\n[bold]Why blocks were left alone[/bold] [dim](the input to the next round)[/dim]")
150
+ for reason, count in sorted(reasons.items(), key=lambda kv: -kv[1])[:15]:
151
+ # A selector is full of square brackets, which Rich reads as markup.
152
+ console.print(f" {count:5d} {escape(reason)}")