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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- 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)
|