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,473 @@
1
+ """Persistent coverage map store (sqlite, stdlib-only).
2
+
3
+ The store is the single source of truth for the coverage map:
4
+
5
+ - ``nodes`` — route patterns, source modules, API endpoints. Identity is
6
+ ``(kind, api, method, key)``; ``api``/``method`` are ``''`` when not
7
+ applicable — NOT NULL, because SQLite UNIQUE indexes treat NULLs as
8
+ distinct and would silently allow duplicate nodes.
9
+ - ``declared`` — provenance rows from manifest sources (``openapi:<name>``,
10
+ ``sitemap``). Replaced per-source on refresh; nodes are never deleted.
11
+ - ``scenario_coverage`` — which scenario exercised which node, with capped
12
+ raw-sample evidence. Replaced per-scenario on merge: the whole prior
13
+ contribution of a scenario is deleted and re-inserted in one transaction,
14
+ so re-running a scenario never accumulates stale rows.
15
+ - ``triage`` — manual ``ui_testable | non_ui | unknown`` flags. Never
16
+ touched by merge or refresh.
17
+ - ``scenarios`` — per-scenario metadata (assert count parsed from the
18
+ scenario markdown at merge time).
19
+
20
+ Provenance (declared / discovered / tested) is DERIVED at read time, never
21
+ stored. All timestamps are supplied by callers — the store never reads the
22
+ wall clock — so merging identical inputs twice produces byte-identical rows.
23
+ ``dump_canonical`` additionally strips all operational metadata (timestamps,
24
+ run ids) so idempotency can be asserted by direct comparison.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import sqlite3
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+
34
+ SCHEMA_VERSION = 1
35
+
36
+ VALID_KINDS = ("route", "module", "endpoint")
37
+ VALID_TRIAGE_FLAGS = ("ui_testable", "non_ui", "unknown")
38
+
39
+ _DDL = """
40
+ CREATE TABLE IF NOT EXISTS meta (
41
+ key TEXT PRIMARY KEY,
42
+ value TEXT NOT NULL
43
+ );
44
+
45
+ CREATE TABLE IF NOT EXISTS nodes (
46
+ id INTEGER PRIMARY KEY,
47
+ kind TEXT NOT NULL CHECK (kind IN ('route', 'module', 'endpoint')),
48
+ api TEXT NOT NULL DEFAULT '',
49
+ method TEXT NOT NULL DEFAULT '',
50
+ key TEXT NOT NULL,
51
+ created_at TEXT NOT NULL DEFAULT '',
52
+ UNIQUE (kind, api, method, key)
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS declared (
56
+ node_id INTEGER NOT NULL REFERENCES nodes(id),
57
+ source TEXT NOT NULL,
58
+ details TEXT NOT NULL DEFAULT '{}',
59
+ updated_at TEXT NOT NULL DEFAULT '',
60
+ PRIMARY KEY (node_id, source)
61
+ );
62
+ CREATE INDEX IF NOT EXISTS idx_declared_source ON declared(source);
63
+
64
+ CREATE TABLE IF NOT EXISTS scenario_coverage (
65
+ node_id INTEGER NOT NULL REFERENCES nodes(id),
66
+ scenario_id TEXT NOT NULL,
67
+ evidence TEXT NOT NULL DEFAULT '{}',
68
+ asserts_enabled INTEGER NOT NULL DEFAULT 1,
69
+ run_id TEXT NOT NULL DEFAULT '',
70
+ recorded_at TEXT NOT NULL DEFAULT '',
71
+ PRIMARY KEY (node_id, scenario_id)
72
+ );
73
+ CREATE INDEX IF NOT EXISTS idx_sc_scenario ON scenario_coverage(scenario_id);
74
+
75
+ CREATE TABLE IF NOT EXISTS triage (
76
+ node_id INTEGER PRIMARY KEY REFERENCES nodes(id),
77
+ flag TEXT NOT NULL CHECK (flag IN ('ui_testable', 'non_ui', 'unknown')),
78
+ note TEXT NOT NULL DEFAULT '',
79
+ updated_at TEXT NOT NULL DEFAULT ''
80
+ );
81
+
82
+ CREATE TABLE IF NOT EXISTS scenarios (
83
+ scenario_id TEXT PRIMARY KEY,
84
+ asserts_count INTEGER NOT NULL DEFAULT 0,
85
+ last_run_id TEXT NOT NULL DEFAULT '',
86
+ last_recorded_at TEXT NOT NULL DEFAULT ''
87
+ );
88
+ """
89
+
90
+
91
+ class StoreError(RuntimeError):
92
+ """Raised on schema-version mismatch or invalid store input."""
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Contribution:
97
+ """One (node, scenario) coverage fact produced by a merge.
98
+
99
+ ``evidence`` is a JSON-serializable dict; the conventional shape is
100
+ ``{"samples": [...], "count": N, "roles": [...], "guide_mode": bool,
101
+ "outcome": "passed"|"failed"|"unknown"}`` with samples capped, sorted,
102
+ and query-stripped.
103
+ """
104
+
105
+ kind: str
106
+ key: str
107
+ api: str = ""
108
+ method: str = ""
109
+ evidence: dict = field(default_factory=dict)
110
+ asserts_enabled: bool = True
111
+
112
+ def identity(self) -> tuple[str, str, str, str]:
113
+ return (self.kind, self.api, self.method, self.key)
114
+
115
+
116
+ def open_store(path: str | Path) -> sqlite3.Connection:
117
+ """Open (creating if needed) the coverage store at ``path``."""
118
+ path = Path(path)
119
+ path.parent.mkdir(parents=True, exist_ok=True)
120
+ conn = sqlite3.connect(str(path))
121
+ conn.row_factory = sqlite3.Row
122
+ conn.isolation_level = None # explicit BEGIN/COMMIT only
123
+ conn.execute("PRAGMA foreign_keys = ON")
124
+ _ensure_schema(conn)
125
+ return conn
126
+
127
+
128
+ def _ensure_schema(conn: sqlite3.Connection) -> None:
129
+ conn.executescript(_DDL)
130
+ row = conn.execute("SELECT value FROM meta WHERE key = 'schema_version'").fetchone()
131
+ if row is None:
132
+ conn.execute(
133
+ "INSERT INTO meta (key, value) VALUES ('schema_version', ?)",
134
+ (str(SCHEMA_VERSION),),
135
+ )
136
+ elif row["value"] != str(SCHEMA_VERSION):
137
+ raise StoreError(
138
+ f"coverage store schema version {row['value']} != supported {SCHEMA_VERSION}; "
139
+ "delete the store file to recreate it (triage flags will be lost) or upgrade steppy"
140
+ )
141
+
142
+
143
+ def ensure_node(
144
+ conn: sqlite3.Connection,
145
+ kind: str,
146
+ key: str,
147
+ api: str = "",
148
+ method: str = "",
149
+ created_at: str = "",
150
+ ) -> int:
151
+ """Insert the node if missing and return its id. Never updates existing rows."""
152
+ if kind not in VALID_KINDS:
153
+ raise StoreError(f"invalid node kind {kind!r} (expected one of {VALID_KINDS})")
154
+ if not key:
155
+ raise StoreError("node key must be non-empty")
156
+ conn.execute(
157
+ "INSERT OR IGNORE INTO nodes (kind, api, method, key, created_at) VALUES (?, ?, ?, ?, ?)",
158
+ (kind, api, method, key, created_at),
159
+ )
160
+ row = conn.execute(
161
+ "SELECT id FROM nodes WHERE kind = ? AND api = ? AND method = ? AND key = ?",
162
+ (kind, api, method, key),
163
+ ).fetchone()
164
+ return int(row["id"])
165
+
166
+
167
+ def replace_scenario_contribution(
168
+ conn: sqlite3.Connection,
169
+ scenario_id: str,
170
+ contributions: list[Contribution],
171
+ *,
172
+ asserts_count: int = 0,
173
+ run_id: str = "",
174
+ recorded_at: str = "",
175
+ ) -> dict:
176
+ """Atomically replace ``scenario_id``'s entire contribution to the map.
177
+
178
+ Idempotent: same inputs -> same rows. Re-running with a smaller set drops
179
+ the stale rows. Nodes referenced by prior contributions are kept (they may
180
+ carry declared rows or triage flags).
181
+ """
182
+ if not scenario_id:
183
+ raise StoreError("scenario_id must be non-empty")
184
+ seen: set[tuple[str, str, str, str]] = set()
185
+ for c in contributions:
186
+ ident = c.identity()
187
+ if ident in seen:
188
+ raise StoreError(f"duplicate contribution for node {ident} in scenario {scenario_id!r}")
189
+ seen.add(ident)
190
+ ordered = sorted(contributions, key=Contribution.identity)
191
+
192
+ conn.execute("BEGIN IMMEDIATE")
193
+ try:
194
+ rows = []
195
+ for c in ordered:
196
+ node_id = ensure_node(conn, c.kind, c.key, c.api, c.method, created_at=recorded_at)
197
+ rows.append(
198
+ (
199
+ node_id,
200
+ scenario_id,
201
+ json.dumps(c.evidence, sort_keys=True),
202
+ 1 if c.asserts_enabled else 0,
203
+ run_id,
204
+ recorded_at,
205
+ )
206
+ )
207
+ conn.execute("DELETE FROM scenario_coverage WHERE scenario_id = ?", (scenario_id,))
208
+ conn.executemany(
209
+ "INSERT INTO scenario_coverage"
210
+ " (node_id, scenario_id, evidence, asserts_enabled, run_id, recorded_at)"
211
+ " VALUES (?, ?, ?, ?, ?, ?)",
212
+ rows,
213
+ )
214
+ conn.execute(
215
+ "INSERT INTO scenarios (scenario_id, asserts_count, last_run_id, last_recorded_at)"
216
+ " VALUES (?, ?, ?, ?)"
217
+ " ON CONFLICT(scenario_id) DO UPDATE SET"
218
+ " asserts_count = excluded.asserts_count,"
219
+ " last_run_id = excluded.last_run_id,"
220
+ " last_recorded_at = excluded.last_recorded_at",
221
+ (scenario_id, asserts_count, run_id, recorded_at),
222
+ )
223
+ conn.execute("COMMIT")
224
+ except BaseException:
225
+ conn.execute("ROLLBACK")
226
+ raise
227
+ return {"scenario_id": scenario_id, "nodes": len(rows)}
228
+
229
+
230
+ @dataclass(frozen=True)
231
+ class DeclaredEntry:
232
+ """One declared-surface item from a manifest source."""
233
+
234
+ kind: str
235
+ key: str
236
+ api: str = ""
237
+ method: str = ""
238
+ details: dict = field(default_factory=dict)
239
+
240
+ def identity(self) -> tuple[str, str, str, str]:
241
+ return (self.kind, self.api, self.method, self.key)
242
+
243
+
244
+ def replace_declared_source(
245
+ conn: sqlite3.Connection,
246
+ source: str,
247
+ entries: list[DeclaredEntry],
248
+ *,
249
+ updated_at: str = "",
250
+ ) -> dict:
251
+ """Atomically replace the declared rows contributed by ``source``.
252
+
253
+ Declared rows for entries that vanished from the manifest are deleted;
254
+ the nodes themselves (and any coverage/triage attached) are kept.
255
+ """
256
+ if not source:
257
+ raise StoreError("declared source must be non-empty")
258
+ seen: set[tuple[str, str, str, str]] = set()
259
+ for e in entries:
260
+ ident = e.identity()
261
+ if ident in seen:
262
+ raise StoreError(f"duplicate declared entry {ident} for source {source!r}")
263
+ seen.add(ident)
264
+ ordered = sorted(entries, key=DeclaredEntry.identity)
265
+
266
+ conn.execute("BEGIN IMMEDIATE")
267
+ try:
268
+ node_ids: list[int] = []
269
+ for e in ordered:
270
+ node_id = ensure_node(conn, e.kind, e.key, e.api, e.method, created_at=updated_at)
271
+ node_ids.append(node_id)
272
+ if node_ids:
273
+ placeholders = ",".join("?" for _ in node_ids)
274
+ removed = conn.execute(
275
+ f"SELECT COUNT(*) AS n FROM declared WHERE source = ? AND node_id NOT IN ({placeholders})",
276
+ (source, *node_ids),
277
+ ).fetchone()["n"]
278
+ conn.execute(
279
+ f"DELETE FROM declared WHERE source = ? AND node_id NOT IN ({placeholders})",
280
+ (source, *node_ids),
281
+ )
282
+ else:
283
+ removed = conn.execute("SELECT COUNT(*) AS n FROM declared WHERE source = ?", (source,)).fetchone()["n"]
284
+ conn.execute("DELETE FROM declared WHERE source = ?", (source,))
285
+ for e, node_id in zip(ordered, node_ids):
286
+ conn.execute(
287
+ "INSERT INTO declared (node_id, source, details, updated_at) VALUES (?, ?, ?, ?)"
288
+ " ON CONFLICT(node_id, source) DO UPDATE SET"
289
+ " details = excluded.details, updated_at = excluded.updated_at",
290
+ (node_id, source, json.dumps(e.details, sort_keys=True), updated_at),
291
+ )
292
+ conn.execute("COMMIT")
293
+ except BaseException:
294
+ conn.execute("ROLLBACK")
295
+ raise
296
+ return {"source": source, "declared": len(node_ids), "removed": int(removed)}
297
+
298
+
299
+ def set_triage(
300
+ conn: sqlite3.Connection,
301
+ *,
302
+ kind: str,
303
+ key: str,
304
+ api: str = "",
305
+ method: str = "",
306
+ flag: str,
307
+ note: str = "",
308
+ updated_at: str = "",
309
+ ) -> int:
310
+ """Set the persisted triage flag on a node (creating the node if absent).
311
+
312
+ Creating on demand lets an operator pre-triage a known non-UI endpoint
313
+ (webhook, cron callback) before any run or refresh has seen it; the same
314
+ node identity is reused when the manifest later declares it.
315
+ """
316
+ if flag not in VALID_TRIAGE_FLAGS:
317
+ raise StoreError(f"invalid triage flag {flag!r} (expected one of {VALID_TRIAGE_FLAGS})")
318
+ conn.execute("BEGIN IMMEDIATE")
319
+ try:
320
+ node_id = ensure_node(conn, kind, key, api, method, created_at=updated_at)
321
+ conn.execute(
322
+ "INSERT INTO triage (node_id, flag, note, updated_at) VALUES (?, ?, ?, ?)"
323
+ " ON CONFLICT(node_id) DO UPDATE SET"
324
+ " flag = excluded.flag, note = excluded.note, updated_at = excluded.updated_at",
325
+ (node_id, flag, note, updated_at),
326
+ )
327
+ conn.execute("COMMIT")
328
+ except BaseException:
329
+ conn.execute("ROLLBACK")
330
+ raise
331
+ return node_id
332
+
333
+
334
+ def list_triage(conn: sqlite3.Connection, flag: str | None = None) -> list[dict]:
335
+ sql = "SELECT n.kind, n.api, n.method, n.key, t.flag, t.note FROM triage t JOIN nodes n ON n.id = t.node_id"
336
+ params: tuple = ()
337
+ if flag is not None:
338
+ sql += " WHERE t.flag = ?"
339
+ params = (flag,)
340
+ sql += " ORDER BY n.kind, n.api, n.method, n.key"
341
+ return [dict(row) for row in conn.execute(sql, params)]
342
+
343
+
344
+ def repoint_node_coverage(conn: sqlite3.Connection, src_node_id: int, dst_node_id: int) -> int:
345
+ """Move scenario_coverage rows from ``src`` to ``dst`` (refresh re-link pass).
346
+
347
+ On (dst, scenario) conflicts the row with the higher evidence ``count``
348
+ wins; ties keep the existing dst row. Returns the number of rows moved.
349
+ """
350
+ if src_node_id == dst_node_id:
351
+ return 0
352
+ conn.execute("BEGIN IMMEDIATE")
353
+ try:
354
+ moved = 0
355
+ src_rows = conn.execute(
356
+ "SELECT * FROM scenario_coverage WHERE node_id = ? ORDER BY scenario_id",
357
+ (src_node_id,),
358
+ ).fetchall()
359
+ for row in src_rows:
360
+ existing = conn.execute(
361
+ "SELECT evidence FROM scenario_coverage WHERE node_id = ? AND scenario_id = ?",
362
+ (dst_node_id, row["scenario_id"]),
363
+ ).fetchone()
364
+ if existing is None:
365
+ conn.execute(
366
+ "UPDATE scenario_coverage SET node_id = ? WHERE node_id = ? AND scenario_id = ?",
367
+ (dst_node_id, src_node_id, row["scenario_id"]),
368
+ )
369
+ moved += 1
370
+ else:
371
+ src_count = (json.loads(row["evidence"]) or {}).get("count", 0)
372
+ dst_count = (json.loads(existing["evidence"]) or {}).get("count", 0)
373
+ if src_count > dst_count:
374
+ conn.execute(
375
+ "DELETE FROM scenario_coverage WHERE node_id = ? AND scenario_id = ?",
376
+ (dst_node_id, row["scenario_id"]),
377
+ )
378
+ conn.execute(
379
+ "UPDATE scenario_coverage SET node_id = ? WHERE node_id = ? AND scenario_id = ?",
380
+ (dst_node_id, src_node_id, row["scenario_id"]),
381
+ )
382
+ moved += 1
383
+ else:
384
+ conn.execute(
385
+ "DELETE FROM scenario_coverage WHERE node_id = ? AND scenario_id = ?",
386
+ (src_node_id, row["scenario_id"]),
387
+ )
388
+ conn.execute("COMMIT")
389
+ except BaseException:
390
+ conn.execute("ROLLBACK")
391
+ raise
392
+ return moved
393
+
394
+
395
+ def observed_endpoint_nodes(conn: sqlite3.Connection) -> list[dict]:
396
+ """Endpoint nodes that have coverage but no declared row — re-link candidates.
397
+
398
+ Each entry carries the union of evidence ``samples`` across scenarios so
399
+ the caller can re-match raw paths against newly declared templates.
400
+ """
401
+ rows = conn.execute(
402
+ "SELECT n.id, n.api, n.method, n.key FROM nodes n"
403
+ " WHERE n.kind = 'endpoint'"
404
+ " AND EXISTS (SELECT 1 FROM scenario_coverage sc WHERE sc.node_id = n.id)"
405
+ " AND NOT EXISTS (SELECT 1 FROM declared d WHERE d.node_id = n.id)"
406
+ " ORDER BY n.api, n.method, n.key"
407
+ ).fetchall()
408
+ result = []
409
+ for row in rows:
410
+ samples: set[str] = set()
411
+ for cov in conn.execute("SELECT evidence FROM scenario_coverage WHERE node_id = ?", (row["id"],)):
412
+ samples.update((json.loads(cov["evidence"]) or {}).get("samples", []))
413
+ result.append(
414
+ {
415
+ "id": int(row["id"]),
416
+ "api": row["api"],
417
+ "method": row["method"],
418
+ "key": row["key"],
419
+ "samples": sorted(samples),
420
+ }
421
+ )
422
+ return result
423
+
424
+
425
+ def read_map(conn: sqlite3.Connection) -> dict:
426
+ """Deterministic full view of the map for gap computation and reporting.
427
+
428
+ Operational metadata (timestamps, run ids) is excluded; everything is
429
+ sorted, so two stores built from the same inputs read identically.
430
+ """
431
+ nodes = []
432
+ for n in conn.execute("SELECT id, kind, api, method, key FROM nodes ORDER BY kind, api, method, key").fetchall():
433
+ declared = [
434
+ {"source": d["source"], "details": json.loads(d["details"])}
435
+ for d in conn.execute(
436
+ "SELECT source, details FROM declared WHERE node_id = ? ORDER BY source",
437
+ (n["id"],),
438
+ )
439
+ ]
440
+ scenarios = [
441
+ {
442
+ "scenario_id": sc["scenario_id"],
443
+ "evidence": json.loads(sc["evidence"]),
444
+ "asserts_enabled": bool(sc["asserts_enabled"]),
445
+ }
446
+ for sc in conn.execute(
447
+ "SELECT scenario_id, evidence, asserts_enabled FROM scenario_coverage"
448
+ " WHERE node_id = ? ORDER BY scenario_id",
449
+ (n["id"],),
450
+ )
451
+ ]
452
+ triage_row = conn.execute("SELECT flag, note FROM triage WHERE node_id = ?", (n["id"],)).fetchone()
453
+ nodes.append(
454
+ {
455
+ "kind": n["kind"],
456
+ "api": n["api"],
457
+ "method": n["method"],
458
+ "key": n["key"],
459
+ "declared": declared,
460
+ "scenarios": scenarios,
461
+ "triage": dict(triage_row) if triage_row else None,
462
+ }
463
+ )
464
+ scenario_meta = {
465
+ row["scenario_id"]: {"asserts_count": int(row["asserts_count"])}
466
+ for row in conn.execute("SELECT scenario_id, asserts_count FROM scenarios ORDER BY scenario_id")
467
+ }
468
+ return {"nodes": nodes, "scenarios": scenario_meta}
469
+
470
+
471
+ def dump_canonical(conn: sqlite3.Connection) -> dict:
472
+ """Canonical store dump for idempotency comparison in tests."""
473
+ return {"schema_version": SCHEMA_VERSION, **read_map(conn)}
@@ -0,0 +1,151 @@
1
+ """Config-declared triage: the ``coverage.non_ui`` overlay.
2
+
3
+ Why this exists at all. The ``ui_testable | non_ui | unknown`` flags live only
4
+ in the ``triage`` table inside ``.steppy/coverage.db``, and ``.steppy/`` is
5
+ gitignored — so a fresh CI checkout has an empty store, every declared endpoint
6
+ reads ``unknown``, and health checks, webhook receivers, OAuth callbacks and
7
+ internal cron endpoints all land in the coverage denominator. A percentage
8
+ computed there is wrong for reasons the maintainer cannot fix from CI, which is
9
+ exactly the kind of number people stop trusting. ``coverage.non_ui`` makes the
10
+ declaration **committed** instead.
11
+
12
+ This module is an *overlay*, applied between ``store.read_map`` and
13
+ ``gaps.compute_gaps``, and it is deliberately not either of them:
14
+
15
+ - ``read_map`` / ``dump_canonical`` are compared byte-wise by the store tests,
16
+ and they describe what is *persisted*. A config-derived flag is not persisted.
17
+ - ``compute_gaps`` is pure bucket math over the map. Teaching it to read config
18
+ patterns would give it two inputs of different kinds.
19
+ - Nothing here writes to the database, which preserves the invariant that merge
20
+ and refresh never touch ``triage``.
21
+
22
+ Pattern grammar — ``[<METHOD> ]<glob>``, matched against a node's ``key``:
23
+
24
+ /health any method, that path
25
+ GET /health that method only
26
+ /internal/* ``fnmatch``, so ``*`` **crosses** ``/``
27
+
28
+ Three consequences worth knowing rather than discovering:
29
+
30
+ 1. Matching is on ``key`` alone, so a pattern applies across **every**
31
+ configured API. That is the useful default — someone writing ``non_ui`` wants
32
+ breadth — and ``steppy coverage triage set`` remains the surgical tool for one
33
+ node.
34
+ 2. ``*`` crossing ``/`` is intentional: ``/internal/*`` covers nested paths.
35
+ 3. A pattern carrying a METHOD never matches a *route* (routes have
36
+ ``method == ""``), because "GET" is meaningless for a page.
37
+
38
+ Precedence is a total order, most specific first::
39
+
40
+ db "ui_testable" > config pattern > db "non_ui" > db "unknown" / no row
41
+
42
+ An explicit human ``ui_testable`` beats a glob: someone typed it about that one
43
+ node. A stored ``unknown`` does *not* beat the glob, because
44
+ ``gaps._unreached_entry`` already collapses "no row" and ``flag="unknown"`` into
45
+ the same rendered value — so letting it override committed config would invent a
46
+ distinction the rest of the system does not make.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import fnmatch
52
+ from typing import Iterable, Optional, Sequence
53
+
54
+ #: Value of ``triage["source"]`` for a flag that came from ``config.yaml``.
55
+ SOURCE_CONFIG = "config"
56
+
57
+ #: Value of ``triage["source"]`` for a flag that came from the sqlite store.
58
+ SOURCE_STORE = "store"
59
+
60
+ _NON_UI = "non_ui"
61
+ _UI_TESTABLE = "ui_testable"
62
+
63
+
64
+ def split_pattern(pattern: str) -> tuple[Optional[str], str]:
65
+ """``"GET /health"`` → ``("GET", "/health")``; ``"/health"`` → ``(None, "/health")``.
66
+
67
+ The method is upper-cased so ``get /health`` and ``GET /health`` are the same
68
+ rule. Only the first whitespace run separates them, so a glob may contain
69
+ spaces (rare, but a path template legally can).
70
+ """
71
+ head, sep, tail = pattern.strip().partition(" ")
72
+ if not sep:
73
+ return None, head
74
+ return head.upper(), tail.strip()
75
+
76
+
77
+ def matches(node: dict, patterns: Sequence[str]) -> Optional[str]:
78
+ """The first pattern in *patterns* that covers *node*, or ``None``.
79
+
80
+ Returning the pattern rather than a bool is what lets the report and
81
+ ``steppy coverage report`` say *which* rule discounted an endpoint — a
82
+ number nobody can trace is a number nobody trusts.
83
+ """
84
+ key = node.get("key") or ""
85
+ method = (node.get("method") or "").upper()
86
+ for pattern in patterns:
87
+ want_method, glob = split_pattern(pattern)
88
+ if not glob:
89
+ continue
90
+ if want_method is not None:
91
+ # Routes carry no method; a method-qualified rule cannot mean them.
92
+ if not method or method != want_method:
93
+ continue
94
+ if fnmatch.fnmatchcase(key, glob):
95
+ return pattern
96
+ return None
97
+
98
+
99
+ def apply_config_triage(map_view: dict, patterns: Sequence[str]) -> dict:
100
+ """``map_view`` with config-matched nodes flagged ``non_ui``.
101
+
102
+ Returns a **new** dict; the input is untouched, because callers hand the same
103
+ map to more than one consumer. Every node's ``triage`` comes back as either
104
+ ``None`` or ``{"flag", "note", "source"}`` — ``source`` is additive, and
105
+ ``gaps`` reads only ``flag``/``note``, so an older reader is unaffected.
106
+
107
+ No patterns ⇒ the store's own flags, annotated with their source. That keeps
108
+ one shape for downstream code instead of two.
109
+ """
110
+ nodes = []
111
+ for node in map_view.get("nodes", []):
112
+ nodes.append({**node, "triage": _resolve(node, patterns)})
113
+ return {**map_view, "nodes": nodes}
114
+
115
+
116
+ def _resolve(node: dict, patterns: Sequence[str]) -> Optional[dict]:
117
+ stored = node.get("triage") or None
118
+ flag = (stored or {}).get("flag")
119
+
120
+ if flag == _UI_TESTABLE:
121
+ # An explicit human decision about this node outranks a glob.
122
+ return {**stored, "source": SOURCE_STORE}
123
+
124
+ hit = matches(node, patterns) if patterns else None
125
+ if hit is not None:
126
+ return {
127
+ "flag": _NON_UI,
128
+ "note": f"coverage.non_ui: {hit}",
129
+ "source": SOURCE_CONFIG,
130
+ }
131
+
132
+ if stored is None:
133
+ return None
134
+ return {**stored, "source": SOURCE_STORE}
135
+
136
+
137
+ def unmatched_patterns(map_view: dict, patterns: Sequence[str]) -> list[str]:
138
+ """Patterns that matched no node — reported, never fatal.
139
+
140
+ A stale rule (an endpoint renamed, a service retired) silently inflates the
141
+ coverage percentage, which is the failure mode this whole feature exists to
142
+ prevent. Cheap to detect here, invisible everywhere else.
143
+ """
144
+ if not patterns:
145
+ return []
146
+ nodes = list(map_view.get("nodes", []))
147
+ return [p for p in patterns if not _any_match(nodes, p)]
148
+
149
+
150
+ def _any_match(nodes: Iterable[dict], pattern: str) -> bool:
151
+ return any(matches(node, (pattern,)) is not None for node in nodes)