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
steppy/update_cli.py ADDED
@@ -0,0 +1,460 @@
1
+ """``steppy update`` — reconcile a scaffolded project with the installed version.
2
+
3
+ This is **not** a self-updater: pip/uv/pipx own upgrading the ``steppy-dev`` package,
4
+ and a CLI can't reliably reinstall itself across venv/uv/pipx environments.
5
+ ``steppy update`` runs *after* a package upgrade and does three things (FLAWS
6
+ §7.7):
7
+
8
+ 1. **Refresh managed scaffold assets** — the config JSON Schema, the packaged
9
+ Claude skills, and the CI workflow — using the ``.steppy/scaffold.json``
10
+ manifest that ``steppy init`` records. Files still matching their recorded
11
+ hash are replaced silently; a file the user has edited is left untouched and
12
+ the new version is written alongside as ``<file>.new`` (the pacman ``.pacnew``
13
+ pattern — never clobber user edits, never silently skip an update either).
14
+ Starter files the user owns (``config.yaml``, scenarios, ``credentials.yaml``)
15
+ are never touched. A skill shipped after the project was scaffolded is
16
+ installed, and the old hand-copied skill folders (``init_cli.LEGACY_SKILLS``)
17
+ are reported.
18
+ 2. **Compatibility report** (``--check``) — a report-only, CI-able scan of
19
+ ``scenarios/`` + ``config.yaml`` against the deprecation registry
20
+ (:mod:`steppy.compat`), exiting non-zero on breakage.
21
+ 3. **Changelog highlights** — the CHANGELOG sections between the version the
22
+ manifest recorded and the installed one, breaking entries flagged.
23
+
24
+ A leaf module: imports only ``_cli_common``, ``init_cli`` (asset primitives),
25
+ ``compat``, ``config_schema`` + stdlib — no cycle with ``cli``. Registered via
26
+ :func:`register_update` at the bottom of ``cli.py``.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import importlib.resources
32
+ import json
33
+ import re
34
+ from pathlib import Path
35
+
36
+ import typer
37
+
38
+ from . import __version__
39
+ from ._cli_common import _cli_errors, _error, console
40
+ from .compat import DEPRECATIONS, Deprecation
41
+ from .config_schema import schema_json
42
+ from .init_cli import APP_SKILL_NAMES, SKILL_NAMES, _bundled, _bundled_skill, _report_legacy_skills, _sha256
43
+
44
+ _MANIFEST_REL = ".steppy/scaffold.json"
45
+ _SCHEMA_FILENAME = "steppy-config.schema.json"
46
+ _CI_WORKFLOW_REL = ".github/workflows/steppy.yml"
47
+ _CI_GITLAB_REL = ".gitlab-ci.yml"
48
+
49
+ # Severity → colour tag for the compat report (matches the rest of the CLI).
50
+ _SEVERITY_STYLE = {"info": "dim", "warning": "yellow", "error": "red"}
51
+
52
+
53
+ # ── Managed-asset refresh ──────────────────────────────────────────────────
54
+
55
+
56
+ def _skill_path(rel: str) -> tuple[str, str] | None:
57
+ """``(base, name)`` for a packaged skill at ``<base>/.claude/skills/<name>/SKILL.md``.
58
+
59
+ ``base`` is ``""`` for the project's own skills, else the app repository
60
+ ``init --app-dir`` installed into, as the manifest records it (``../app``).
61
+ ``None`` for anything else, a skill of the user's own included.
62
+ """
63
+ parts = rel.split("/")
64
+ if (
65
+ len(parts) >= 4
66
+ and parts[-4:-2] == [".claude", "skills"]
67
+ and parts[-1] == "SKILL.md"
68
+ and parts[-2] in SKILL_NAMES + APP_SKILL_NAMES
69
+ ):
70
+ return "/".join(parts[:-4]), parts[-2]
71
+ return None
72
+
73
+
74
+ def _refreshable_content(rel: str) -> str | None:
75
+ """Fresh bundled content for a *managed* scaffold asset, or ``None``.
76
+
77
+ ``None`` means "not a Steppy-owned asset" — a starter file the user owns
78
+ (``config.yaml``, scenarios, ``credentials.yaml.example``, ``.gitignore``),
79
+ which ``update`` never touches. The classification is purely by relpath so it
80
+ stays in lockstep with what ``init`` lays down.
81
+ """
82
+ if rel == _SCHEMA_FILENAME:
83
+ return schema_json()
84
+ if rel == _CI_WORKFLOW_REL:
85
+ return _bundled("ci", "github-actions.yml").read_text(encoding="utf-8")
86
+ if rel == _CI_GITLAB_REL:
87
+ return _bundled("ci", "gitlab-ci.yml").read_text(encoding="utf-8")
88
+ skill = _skill_path(rel)
89
+ if skill is not None:
90
+ return _bundled_skill(skill[1], "SKILL.md").read_text(encoding="utf-8")
91
+ return None
92
+
93
+
94
+ def _install_new_skills(root: Path, files: dict[str, str]) -> bool:
95
+ """Install the skills this version ships that the manifest has never recorded.
96
+
97
+ Done per skill folder the manifest knows: the project's (every skill in
98
+ ``SKILL_NAMES``) and each app's from ``init --app-dir`` (``APP_SKILL_NAMES``).
99
+ A folder qualifies only while one of its recorded skills is still on disk, so
100
+ a project scaffolded with ``--no-skills``, or whose skills were all deleted,
101
+ gets none. A recorded skill the user deleted stays deleted: that is
102
+ :func:`_refresh_assets`' rule, and this never sees recorded paths.
103
+
104
+ A file already at the path is never overwritten. If it matches the shipped
105
+ skill it is adopted; otherwise the shipped one is written beside it as
106
+ ``SKILL.md.new``, like any diverged asset. Returns whether anything was reported.
107
+ """
108
+ in_use: dict[str, bool] = {}
109
+ for rel in files:
110
+ skill = _skill_path(rel)
111
+ if skill is not None:
112
+ in_use[skill[0]] = in_use.get(skill[0], False) or (root / rel).is_file()
113
+
114
+ acted = False
115
+ for base, used in sorted(in_use.items()):
116
+ if not used:
117
+ continue
118
+ for name in SKILL_NAMES if base == "" else APP_SKILL_NAMES:
119
+ rel = f"{base}/.claude/skills/{name}/SKILL.md" if base else f".claude/skills/{name}/SKILL.md"
120
+ if rel in files:
121
+ continue
122
+ content = _bundled_skill(name, "SKILL.md").read_text(encoding="utf-8")
123
+ target = root / rel
124
+ if not target.exists():
125
+ target.parent.mkdir(parents=True, exist_ok=True)
126
+ target.write_text(content, encoding="utf-8", newline="\n")
127
+ files[rel] = _sha256(content)
128
+ console.print(f" [green]install[/green] {rel} (new in this version)")
129
+ acted = True
130
+ elif _sha256(target.read_text(encoding="utf-8")) == _sha256(content):
131
+ files[rel] = _sha256(content)
132
+ else:
133
+ target.with_name("SKILL.md.new").write_text(content, encoding="utf-8", newline="\n")
134
+ console.print(
135
+ f" [yellow]diverged[/yellow] {rel} — not written by Steppy; kept it, "
136
+ f"the shipped skill is in {rel}.new"
137
+ )
138
+ acted = True
139
+ return acted
140
+
141
+
142
+ def _refresh_assets(root: Path, manifest: dict) -> None:
143
+ """Reconcile managed assets against the installed bundle (pacman pattern).
144
+
145
+ Mutates ``manifest['files']`` in place with the new baselines so the caller
146
+ can persist it. Reports each action to the console.
147
+ """
148
+ files: dict[str, str] = manifest.get("files", {})
149
+ any_action = False
150
+
151
+ for rel in sorted(files):
152
+ new_content = _refreshable_content(rel)
153
+ if new_content is None:
154
+ continue # starter file — user owns it, never touched.
155
+ recorded = files[rel]
156
+ new_hash = _sha256(new_content)
157
+ target = root / rel
158
+
159
+ if not target.exists():
160
+ # User deleted it deliberately — don't resurrect it.
161
+ console.print(f" [dim]absent[/dim] {rel} (removed; not restored)")
162
+ any_action = True
163
+ continue
164
+
165
+ current = _sha256(target.read_text(encoding="utf-8"))
166
+
167
+ if current == recorded:
168
+ # Pristine — safe to replace.
169
+ if new_hash != recorded:
170
+ target.write_text(new_content, encoding="utf-8", newline="\n")
171
+ files[rel] = new_hash
172
+ console.print(f" [green]update[/green] {rel}")
173
+ any_action = True
174
+ # else: already up to date — say nothing.
175
+ else:
176
+ # User-modified since we last wrote it.
177
+ if new_hash == current:
178
+ # Their edits happen to match the new version — bless it.
179
+ files[rel] = new_hash
180
+ elif new_hash == recorded:
181
+ # No new version shipped; keep their edits, stay quiet.
182
+ pass
183
+ else:
184
+ new_path = target.with_name(target.name + ".new")
185
+ new_path.write_text(new_content, encoding="utf-8", newline="\n")
186
+ console.print(f" [yellow]diverged[/yellow] {rel} — kept your copy; new version written to {rel}.new")
187
+ # Leave the manifest baseline unchanged so we keep re-detecting
188
+ # the divergence until the user merges.
189
+ any_action = True
190
+
191
+ if _install_new_skills(root, files):
192
+ any_action = True
193
+ if _report_legacy_skills(root):
194
+ any_action = True
195
+
196
+ if not any_action:
197
+ console.print(" [dim]all managed assets up to date[/dim]")
198
+
199
+
200
+ def _rewrite_manifest(root: Path, manifest: dict) -> None:
201
+ """Persist the manifest, bumping ``steppy_version`` to the installed one.
202
+
203
+ Same serialization as ``init`` (:func:`init_cli._write_manifest`) but merges
204
+ — non-managed (starter) entries are preserved verbatim.
205
+ """
206
+ manifest["steppy_version"] = __version__
207
+ manifest["files"] = dict(sorted(manifest.get("files", {}).items()))
208
+ (root / _MANIFEST_REL).write_text(
209
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n",
210
+ encoding="utf-8",
211
+ newline="\n",
212
+ )
213
+
214
+
215
+ # ── Compatibility scan ─────────────────────────────────────────────────────
216
+
217
+
218
+ def _dep(dep_id: str) -> Deprecation:
219
+ return next(d for d in DEPRECATIONS if d.id == dep_id)
220
+
221
+
222
+ def _scan_scenarios_for_action(root: Path, action: str) -> list[tuple[Path, int, str]]:
223
+ """Raw line scan of scenarios/ for a deprecated step-action spelling.
224
+
225
+ Raw text (not the parser) because ``models.py`` normalizes the alias away at
226
+ parse time — by the time a ``Scenario`` exists the original spelling is gone.
227
+ """
228
+ pattern = re.compile(rf"^\s*-\s*{re.escape(action)}\b")
229
+ hits: list[tuple[Path, int, str]] = []
230
+ scenarios_dir = root / "scenarios"
231
+ if not scenarios_dir.is_dir():
232
+ return hits
233
+ for path in sorted(scenarios_dir.rglob("*.md")):
234
+ for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
235
+ if pattern.search(line):
236
+ hits.append((path, lineno, line.strip()))
237
+ return hits
238
+
239
+
240
+ def _scan_config_auth_mode(root: Path) -> list[tuple[Path, int, str]]:
241
+ """Raw line scan of config.yaml for ``mode: auth0`` (the deprecated mode)."""
242
+ pattern = re.compile(r"^\s*mode:\s*['\"]?auth0\b")
243
+ return _scan_config_lines(root, pattern)
244
+
245
+
246
+ def _scan_config_nested_key(root: Path, parent: str, child: str) -> list[tuple[Path, int, str]]:
247
+ """Detect a ``<child>:`` key nested directly under top-level ``<parent>:``.
248
+
249
+ Indent-aware on purpose: a bare ``child`` regex would also match the same
250
+ word appearing under some other block (a per-app ``mode: auth0``, another
251
+ section's ``reports_dir``). Raw text rather than the parser, for the same
252
+ reason the action scan is: by the time a model exists the deprecated
253
+ spelling has been normalized or dropped.
254
+ """
255
+ cfg = root / "config.yaml"
256
+ hits: list[tuple[Path, int, str]] = []
257
+ if not cfg.is_file():
258
+ return hits
259
+ parent_re = re.compile(rf"^{re.escape(parent)}:\s*(#.*)?$")
260
+ child_re = re.compile(rf"^{re.escape(child)}:\s*")
261
+ in_parent = False
262
+ parent_indent = 0
263
+ for lineno, raw in enumerate(cfg.read_text(encoding="utf-8").splitlines(), 1):
264
+ stripped = raw.strip()
265
+ if not stripped or stripped.startswith("#"):
266
+ continue
267
+ indent = len(raw) - len(raw.lstrip())
268
+ if not in_parent:
269
+ if parent_re.match(raw):
270
+ in_parent = True
271
+ parent_indent = indent
272
+ continue
273
+ # Inside the block — a key at the same indent ends it.
274
+ if indent <= parent_indent:
275
+ in_parent = parent_re.match(raw) is not None
276
+ parent_indent = indent if in_parent else parent_indent
277
+ continue
278
+ if child_re.match(stripped):
279
+ hits.append((cfg, lineno, stripped))
280
+ return hits
281
+
282
+
283
+ def _scan_config_lines(root: Path, pattern: re.Pattern) -> list[tuple[Path, int, str]]:
284
+ cfg = root / "config.yaml"
285
+ hits: list[tuple[Path, int, str]] = []
286
+ if not cfg.is_file():
287
+ return hits
288
+ for lineno, line in enumerate(cfg.read_text(encoding="utf-8").splitlines(), 1):
289
+ if pattern.search(line):
290
+ hits.append((cfg, lineno, line.strip()))
291
+ return hits
292
+
293
+
294
+ def _scan_deprecations(root: Path) -> list[dict]:
295
+ """Scan a project for every known deprecated spelling.
296
+
297
+ Returns a list of ``{deprecation, file, line, text}`` hits. Matchers are keyed
298
+ to :data:`steppy.compat.DEPRECATIONS` entries by id — this is the live-matcher
299
+ layer the ``compat`` docstring anticipates.
300
+ """
301
+ scans = (
302
+ (_dep("action-auth0-login-to-login"), _scan_scenarios_for_action(root, "auth0-login")),
303
+ (_dep("auth-mode-auth0-to-form"), _scan_config_auth_mode(root)),
304
+ (_dep("auth-global-key-auth0-to-form"), _scan_config_nested_key(root, "auth", "auth0")),
305
+ (_dep("output-reports-dir-inert"), _scan_config_nested_key(root, "output", "reports_dir")),
306
+ )
307
+ hits: list[dict] = []
308
+ for dep, found in scans:
309
+ for path, line, text in found:
310
+ hits.append({"deprecation": dep, "file": path, "line": line, "text": text})
311
+ return hits
312
+
313
+
314
+ def _report_deprecations(root: Path, hits: list[dict]) -> None:
315
+ if not hits:
316
+ console.print(" [dim]no deprecated spellings found[/dim]")
317
+ return
318
+ for hit in hits:
319
+ dep: Deprecation = hit["deprecation"]
320
+ style = _SEVERITY_STYLE.get(dep.severity, "dim")
321
+ rel = hit["file"].relative_to(root).as_posix()
322
+ console.print(f" [{style}]{dep.severity}[/{style}] {rel}:{hit['line']} — {dep.message}")
323
+
324
+
325
+ def _exit_code_for(hits: list[dict]) -> int:
326
+ """Non-zero only on breakage (an ``error``-severity hit)."""
327
+ return 1 if any(h["deprecation"].severity == "error" for h in hits) else 0
328
+
329
+
330
+ # ── Changelog highlights ───────────────────────────────────────────────────
331
+
332
+ _VERSION_RE = re.compile(r"^##\s*\[?(\d+(?:\.\d+)*)\]?")
333
+
334
+
335
+ def _parse_version(text: str) -> tuple[int, ...] | None:
336
+ try:
337
+ return tuple(int(p) for p in text.split("."))
338
+ except ValueError:
339
+ return None
340
+
341
+
342
+ def _bundled_changelog() -> str | None:
343
+ try:
344
+ path = Path(importlib.resources.files("steppy") / "CHANGELOG.md")
345
+ except (ModuleNotFoundError, FileNotFoundError):
346
+ return None
347
+ return path.read_text(encoding="utf-8") if path.is_file() else None
348
+
349
+
350
+ def _changelog_sections(text: str) -> list[tuple[tuple[int, ...], str, str]]:
351
+ """Split a Keep-a-Changelog body into ``(version_tuple, heading, body)``.
352
+
353
+ ``[Unreleased]`` and any non-numeric heading are skipped (no version tuple).
354
+ """
355
+ lines = text.splitlines()
356
+ starts = [i for i, ln in enumerate(lines) if ln.startswith("## ")]
357
+ sections: list[tuple[tuple[int, ...], str, str]] = []
358
+ for idx, start in enumerate(starts):
359
+ end = starts[idx + 1] if idx + 1 < len(starts) else len(lines)
360
+ heading = lines[start]
361
+ m = _VERSION_RE.match(heading)
362
+ if not m:
363
+ continue
364
+ version = _parse_version(m.group(1))
365
+ if version is None:
366
+ continue
367
+ body = "\n".join(lines[start + 1 : end]).strip("\n")
368
+ sections.append((version, heading.lstrip("# ").strip(), body))
369
+ return sections
370
+
371
+
372
+ def _changelog_highlights(recorded_version: str) -> None:
373
+ """Print CHANGELOG sections in ``(recorded, installed]``, flagging breaking."""
374
+ text = _bundled_changelog()
375
+ if not text:
376
+ return
377
+ lo = _parse_version(recorded_version)
378
+ hi = _parse_version(__version__)
379
+ if lo is None or hi is None:
380
+ return
381
+ selected = [(heading, body) for version, heading, body in _changelog_sections(text) if lo < version <= hi]
382
+ if not selected:
383
+ console.print(f" [dim]no changelog entries between {recorded_version} and {__version__}[/dim]")
384
+ return
385
+ for heading, body in selected:
386
+ breaking = bool(re.search(r"breaking|### Removed", body, re.IGNORECASE))
387
+ tag = " [red](breaking)[/red]" if breaking else ""
388
+ console.print(f" [bold]{heading}[/bold]{tag}")
389
+ for line in body.splitlines():
390
+ if line.strip():
391
+ console.print(f" {line}")
392
+
393
+
394
+ # ── Command ────────────────────────────────────────────────────────────────
395
+
396
+
397
+ def _run_update(directory: Path, check: bool = False) -> None:
398
+ root = directory.resolve()
399
+
400
+ # `--check` is a pure, manifest-free compat scan (CI-able).
401
+ if check:
402
+ console.print("[bold]Compatibility check[/bold]")
403
+ hits = _scan_deprecations(root)
404
+ _report_deprecations(root, hits)
405
+ code = _exit_code_for(hits)
406
+ if code:
407
+ _error("compatibility check found breaking usage — see above")
408
+ raise typer.Exit(code)
409
+
410
+ manifest_path = root / _MANIFEST_REL
411
+ if not manifest_path.is_file():
412
+ _error(
413
+ "not a Steppy-scaffolded project (no .steppy/scaffold.json) — run `steppy init` first; "
414
+ "in an existing project it only adds what is missing"
415
+ )
416
+ raise typer.Exit(1)
417
+
418
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
419
+ recorded_version = manifest.get("steppy_version", "0.0.0")
420
+
421
+ console.print(f"[bold]Updating project[/bold] (scaffolded with {recorded_version}, installed {__version__})\n")
422
+
423
+ console.print("[bold]Managed assets[/bold]")
424
+ _refresh_assets(root, manifest)
425
+ _rewrite_manifest(root, manifest)
426
+
427
+ console.print("\n[bold]Compatibility[/bold]")
428
+ _report_deprecations(root, _scan_deprecations(root))
429
+
430
+ console.print("\n[bold]Changelog[/bold]")
431
+ _changelog_highlights(recorded_version)
432
+
433
+
434
+ def register_update(app: typer.Typer) -> None:
435
+ """Register the ``update`` command on the root Typer app."""
436
+
437
+ @app.command("update")
438
+ def update(
439
+ directory: Path = typer.Argument(
440
+ Path("."),
441
+ help="Project directory to reconcile (default: current directory).",
442
+ ),
443
+ check: bool = typer.Option(
444
+ False,
445
+ "--check",
446
+ help="Only scan scenarios/config for deprecated usage (no writes); "
447
+ "exit non-zero on breaking usage. CI-able.",
448
+ ),
449
+ ):
450
+ """Reconcile a scaffolded project with the installed Steppy version.
451
+
452
+ Refreshes Steppy-owned assets (config schema, Claude skills, CI workflow)
453
+ from the installed package — pristine files silently, user-edited files
454
+ alongside as <file>.new — then reports deprecated usage and changelog
455
+ highlights. Starter files you own (config.yaml, scenarios) are untouched.
456
+ Only assets recorded in .steppy/scaffold.json are managed, plus the skills
457
+ shipped since, which are installed beside them.
458
+ """
459
+ with _cli_errors():
460
+ _run_update(directory, check)