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/coverage/store.py
ADDED
|
@@ -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)
|