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,9 @@
1
+ """Deterministic coverage substrate: record -> merge -> map store -> gap report.
2
+
3
+ Recording happens inside the *generated* conftest (env-gated on
4
+ ``STEPPY_COVERAGE=1``); everything in this package runs steppy-side and is
5
+ lazy-imported from the CLI only — ``steppy run`` without ``--coverage`` never
6
+ imports it (guarded by ``tests/test_no_coverage_import_on_default_path.py``).
7
+
8
+ No LLM calls anywhere: recording, merge, and gap compute are deterministic.
9
+ """
@@ -0,0 +1,190 @@
1
+ """Gap computation: pure bucket math over the coverage map.
2
+
3
+ Consumes ``store.read_map()`` output, produces the bucket structure the
4
+ report renders. No IO, no clock — same map in, same buckets out.
5
+
6
+ Buckets per kind (endpoints are the primary surface, routes when a sitemap
7
+ is configured):
8
+
9
+ - ``tested`` — declared AND exercised by at least one scenario, with a
10
+ ``strength``: *strong* when some covering scenario both ran with asserts
11
+ enabled and contains at least one assert step; *weak* otherwise.
12
+ - ``declared_unreached`` — in the manifest, never hit by any scenario. The
13
+ key bucket. Each entry carries its triage flag so non-UI endpoints
14
+ (webhooks, cron callbacks) can be discounted, plus ``triage_source``
15
+ saying whether that came from ``config.yaml`` or the store's own table.
16
+ - ``discovered_uncovered`` — seen in a trail but only weakly covered.
17
+ - ``discovered_tested`` — exercised + strongly asserted but absent from the
18
+ manifest (stale spec, or a base-URL misconfiguration).
19
+ - ``unclassified`` (endpoints only) — requests that matched no configured
20
+ API base URL.
21
+
22
+ Modules have no declared source in v1 — they are reported as a covered
23
+ inventory only.
24
+
25
+ ``e2e_surface`` sits beside ``counts`` and answers the badge's question for each
26
+ surface (see :func:`surface_ratio`). It is derived, not another bucket: nothing
27
+ here decides *which* nodes are non-UI — that is the read-time overlay in
28
+ :mod:`steppy.coverage.triage`, applied to the map before it arrives.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ #: Surfaces the e2e ratio can be computed over. ``all`` is the default because
34
+ #: the declared surface is "routes and API endpoints" — either alone silently
35
+ #: drops half of what a project declared.
36
+ SURFACES = ("all", "endpoints", "routes")
37
+
38
+
39
+ def surface_ratio(counts: dict, surface: str = "all") -> dict:
40
+ """``tested / declared`` over one surface, with non_ui removed from both.
41
+
42
+ The formula as written in the docs — ``tested / (declared - non_ui)`` — can
43
+ exceed 1, because a *tested* endpoint flagged ``non_ui`` stays in the
44
+ numerator while leaving the denominator. Subtracting from both keeps the ratio
45
+ in range and keeps the question honest: *of the surface a UI test could reach,
46
+ how much does the suite reach?*
47
+
48
+ ``total == 0`` returns ``ratio``/``percent`` of ``None`` rather than 0, so a
49
+ project with nothing declared reads as "not measured" instead of "0% tested".
50
+ """
51
+ kinds = ("endpoints", "routes") if surface == "all" else (surface,)
52
+ tested = 0
53
+ total = 0
54
+ for kind in kinds:
55
+ section = counts.get(kind) or {}
56
+ tested += section.get("tested", 0) - section.get("tested_non_ui", 0)
57
+ total += section.get("declared", 0) - section.get("declared_non_ui", 0)
58
+ if total <= 0:
59
+ return {"surface": surface, "tested": max(tested, 0), "declared": max(total, 0), "ratio": None, "percent": None}
60
+ ratio = tested / total
61
+ return {
62
+ "surface": surface,
63
+ "tested": tested,
64
+ "declared": total,
65
+ "ratio": round(ratio, 4),
66
+ "percent": int(round(ratio * 100)),
67
+ }
68
+
69
+
70
+ def _strength(node: dict, scenarios_meta: dict) -> str:
71
+ """Strong iff some covering scenario has asserts enabled AND assert steps."""
72
+ for cov in node["scenarios"]:
73
+ meta = scenarios_meta.get(cov["scenario_id"], {})
74
+ if cov["asserts_enabled"] and meta.get("asserts_count", 0) > 0:
75
+ return "strong"
76
+ return "weak"
77
+
78
+
79
+ def _covered_entry(node: dict, scenarios_meta: dict) -> dict:
80
+ # ``triage`` rides on a *covered* entry too, which it historically did not.
81
+ # Without it the e2e-surface ratio cannot subtract a non_ui endpoint that
82
+ # happens to be tested, and the number could exceed 100%.
83
+ triage = node.get("triage") or {}
84
+ return {
85
+ "api": node["api"],
86
+ "method": node["method"],
87
+ "key": node["key"],
88
+ "scenarios": [c["scenario_id"] for c in node["scenarios"]],
89
+ "strength": _strength(node, scenarios_meta),
90
+ "triage": triage.get("flag", "unknown"),
91
+ "triage_source": triage.get("source", ""),
92
+ "note": triage.get("note", ""),
93
+ }
94
+
95
+
96
+ def _unreached_entry(node: dict) -> dict:
97
+ triage = node.get("triage") or {}
98
+ return {
99
+ "api": node["api"],
100
+ "method": node["method"],
101
+ "key": node["key"],
102
+ "triage": triage.get("flag", "unknown"),
103
+ # Which rule discounted this endpoint: "config" (a coverage.non_ui
104
+ # pattern), "store" (a triage row), or "" (neither). A number nobody can
105
+ # trace is a number nobody trusts.
106
+ "triage_source": triage.get("source", ""),
107
+ "note": triage.get("note", ""),
108
+ }
109
+
110
+
111
+ def compute_gaps(map_view: dict) -> dict:
112
+ """Bucket every node of the map. ``map_view`` comes from ``store.read_map``."""
113
+ scenarios_meta = map_view["scenarios"]
114
+ buckets: dict = {
115
+ "endpoints": {
116
+ "tested": [],
117
+ "declared_unreached": [],
118
+ "discovered_uncovered": [],
119
+ "discovered_tested": [],
120
+ "unclassified": [],
121
+ },
122
+ "routes": {
123
+ "tested": [],
124
+ "declared_unreached": [],
125
+ "discovered_uncovered": [],
126
+ "discovered_tested": [],
127
+ },
128
+ "modules": {"covered": []},
129
+ }
130
+
131
+ for node in map_view["nodes"]:
132
+ declared = bool(node["declared"])
133
+ covered = bool(node["scenarios"])
134
+ if not declared and not covered:
135
+ continue # placeholder left by a re-link, or triage-only node
136
+
137
+ if node["kind"] == "module":
138
+ if covered:
139
+ buckets["modules"]["covered"].append(
140
+ {
141
+ "key": node["key"],
142
+ "scenarios": [c["scenario_id"] for c in node["scenarios"]],
143
+ }
144
+ )
145
+ continue
146
+
147
+ kind_bucket = buckets["endpoints" if node["kind"] == "endpoint" else "routes"]
148
+
149
+ if node["kind"] == "endpoint" and covered and not declared and node["api"] == "":
150
+ kind_bucket["unclassified"].append(_covered_entry(node, scenarios_meta))
151
+ continue
152
+
153
+ if declared and covered:
154
+ kind_bucket["tested"].append(_covered_entry(node, scenarios_meta))
155
+ elif declared:
156
+ kind_bucket["declared_unreached"].append(_unreached_entry(node))
157
+ else:
158
+ entry = _covered_entry(node, scenarios_meta)
159
+ if entry["strength"] == "weak":
160
+ kind_bucket["discovered_uncovered"].append(entry)
161
+ else:
162
+ kind_bucket["discovered_tested"].append(entry)
163
+
164
+ counts: dict = {"scenarios": len(scenarios_meta)}
165
+ for kind in ("endpoints", "routes"):
166
+ section = buckets[kind]
167
+ tested = section["tested"]
168
+ unreached = section["declared_unreached"]
169
+ tested_non_ui = sum(1 for e in tested if e["triage"] == "non_ui")
170
+ unreached_non_ui = sum(1 for e in unreached if e["triage"] == "non_ui")
171
+ counts[kind] = {
172
+ "declared": len(tested) + len(unreached),
173
+ "tested": len(tested),
174
+ "tested_strong": sum(1 for e in tested if e["strength"] == "strong"),
175
+ "declared_unreached": len(unreached),
176
+ "declared_unreached_actionable": len(unreached) - unreached_non_ui,
177
+ "discovered_uncovered": len(section["discovered_uncovered"]),
178
+ "discovered_tested": len(section["discovered_tested"]),
179
+ # non_ui across tested UNION unreached. Counting it only among the
180
+ # unreached (which ``declared_unreached_actionable`` does, and which
181
+ # is right for its own purpose) would leave a tested-but-non_ui
182
+ # endpoint in the numerator and out of the denominator.
183
+ "tested_non_ui": tested_non_ui,
184
+ "declared_non_ui": tested_non_ui + unreached_non_ui,
185
+ }
186
+ counts["endpoints"]["unclassified"] = len(buckets["endpoints"]["unclassified"])
187
+ counts["modules"] = {"covered": len(buckets["modules"]["covered"])}
188
+ buckets["counts"] = counts
189
+ buckets["e2e_surface"] = {surface: surface_ratio(counts, surface) for surface in SURFACES}
190
+ return buckets
@@ -0,0 +1,193 @@
1
+ """Declared-surface ingest: OpenAPI endpoints + optional sitemap routes.
2
+
3
+ ``refresh_declared`` is the one entry point: it loads each configured
4
+ manifest (http(s) URL via httpx, or a local file path — local paths keep
5
+ tests offline and support repos that commit their spec), parses it as
6
+ **JSON or YAML** (a served ``/openapi.json`` is the first, a spec committed
7
+ to a repo is usually the second), replaces the
8
+ per-source declared rows in the store, then runs the **re-link pass**:
9
+ observed endpoint nodes (coverage but no declared row) whose evidence
10
+ samples now match a declared template get their coverage re-pointed onto
11
+ the declared node. That makes ``merge -> refresh`` and ``refresh -> merge``
12
+ converge to the same map regardless of order.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import xml.etree.ElementTree as ET
19
+ from pathlib import Path
20
+
21
+ import yaml
22
+
23
+ from .normalize import compile_extra_patterns, match_template, normalize_route
24
+ from .store import (
25
+ DeclaredEntry,
26
+ observed_endpoint_nodes,
27
+ replace_declared_source,
28
+ repoint_node_coverage,
29
+ )
30
+
31
+ _HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
32
+
33
+
34
+ class ManifestError(RuntimeError):
35
+ """Raised when a manifest cannot be loaded or parsed."""
36
+
37
+
38
+ def load_text(location: str) -> str:
39
+ """Load manifest text from an http(s) URL or a local file path."""
40
+ if location.startswith(("http://", "https://")):
41
+ import httpx # local import: keeps merge/report paths network-free
42
+
43
+ try:
44
+ response = httpx.get(location, timeout=30.0, follow_redirects=True)
45
+ response.raise_for_status()
46
+ except httpx.HTTPError as exc:
47
+ raise ManifestError(f"failed to fetch {location}: {exc}") from exc
48
+ return response.text
49
+ path = Path(location)
50
+ try:
51
+ return path.read_text(encoding="utf-8")
52
+ except OSError as exc:
53
+ raise ManifestError(f"failed to read {location}: {exc}") from exc
54
+
55
+
56
+ def parse_manifest_document(text: str, api_name: str):
57
+ """Parse an OpenAPI document from either JSON or YAML.
58
+
59
+ JSON is tried first: it is what an application serves at ``/openapi.json``,
60
+ it is the fast path on a large spec, and its error names the exact offset.
61
+ YAML is the fallback because a spec **committed to a repo** is usually
62
+ ``.yaml`` — and ``load_text`` has always accepted a local path, so refusing
63
+ to parse the file it fetched was a gap rather than a policy.
64
+
65
+ Not YAML-only, even though YAML is a JSON superset: parsing a multi-megabyte
66
+ generated spec through the YAML loader is markedly slower, for a document
67
+ that is already known to be JSON in the common case.
68
+
69
+ The return type is deliberately unconstrained — shape validation belongs to
70
+ :func:`parse_openapi_endpoints`, which is where a non-mapping document has to
71
+ be rejected anyway, and duplicating the check would let the two disagree.
72
+ """
73
+ try:
74
+ return json.loads(text)
75
+ except ValueError:
76
+ pass
77
+ try:
78
+ return yaml.safe_load(text)
79
+ except yaml.YAMLError as exc:
80
+ raise ManifestError(f"OpenAPI for {api_name!r} is not valid JSON or YAML: {exc}") from exc
81
+
82
+
83
+ def parse_openapi_endpoints(doc: dict, api_name: str) -> list[DeclaredEntry]:
84
+ """Extract ``paths`` x methods from an OpenAPI document.
85
+
86
+ Non-operation keys on path items (``parameters``, ``servers``, ``$ref``,
87
+ ``x-*`` extensions, ``summary``/``description``) are skipped.
88
+ """
89
+ if not isinstance(doc, dict):
90
+ # Format-neutral: the document may have arrived as YAML, and a YAML file
91
+ # that parses to a list would otherwise be reported as bad JSON.
92
+ raise ManifestError(f"OpenAPI document for {api_name!r} is not a mapping")
93
+ paths = doc.get("paths") or {}
94
+ entries: list[DeclaredEntry] = []
95
+ for path in sorted(paths):
96
+ item = paths[path]
97
+ if not isinstance(item, dict):
98
+ continue
99
+ for method in _HTTP_METHODS:
100
+ operation = item.get(method)
101
+ if not isinstance(operation, dict):
102
+ continue
103
+ details = {k: operation[k] for k in ("operationId", "summary", "tags") if k in operation}
104
+ entries.append(
105
+ DeclaredEntry(
106
+ kind="endpoint",
107
+ key=path,
108
+ api=api_name,
109
+ method=method.upper(),
110
+ details=details,
111
+ )
112
+ )
113
+ return entries
114
+
115
+
116
+ def parse_sitemap_routes(xml_text: str, route_param_patterns: list[str] | None = None) -> list[DeclaredEntry]:
117
+ """Extract ``<loc>`` URLs from a sitemap, normalized to route patterns.
118
+
119
+ Namespace-agnostic (matches any ``*}loc`` tag). Concrete URLs are run
120
+ through the same route normalization as observed navigations so declared
121
+ and observed route nodes share identities.
122
+ """
123
+ try:
124
+ root = ET.fromstring(xml_text)
125
+ except ET.ParseError as exc:
126
+ raise ManifestError(f"invalid sitemap XML: {exc}") from exc
127
+ extra = compile_extra_patterns(route_param_patterns or [])
128
+ keys = sorted(
129
+ {
130
+ normalize_route(el.text.strip(), extra)
131
+ for el in root.iter()
132
+ if el.tag.rsplit("}", 1)[-1] == "loc" and el.text and el.text.strip()
133
+ }
134
+ )
135
+ return [DeclaredEntry(kind="route", key=key) for key in keys]
136
+
137
+
138
+ def relink_observed_endpoints(conn) -> int:
139
+ """Re-point coverage from observed endpoint nodes onto declared templates.
140
+
141
+ For each endpoint node with coverage but no declared row, retry its
142
+ evidence samples (raw relative paths) against the declared templates of
143
+ the same (api, method). First matching sample wins (samples are sorted,
144
+ so this is deterministic). The emptied observed node is kept — nodes are
145
+ never deleted implicitly.
146
+ """
147
+ templates: dict[tuple[str, str], dict[str, int]] = {}
148
+ for row in conn.execute(
149
+ "SELECT DISTINCT n.id, n.api, n.method, n.key FROM nodes n"
150
+ " JOIN declared d ON d.node_id = n.id WHERE n.kind = 'endpoint'"
151
+ ):
152
+ templates.setdefault((row["api"], row["method"]), {})[row["key"]] = int(row["id"])
153
+
154
+ moved = 0
155
+ for candidate in observed_endpoint_nodes(conn):
156
+ candidates = templates.get((candidate["api"], candidate["method"]))
157
+ if not candidates:
158
+ continue
159
+ target_key = None
160
+ for sample in candidate["samples"]:
161
+ target_key = match_template(sample, list(candidates))
162
+ if target_key:
163
+ break
164
+ if target_key:
165
+ moved += repoint_node_coverage(conn, candidate["id"], candidates[target_key])
166
+ return moved
167
+
168
+
169
+ def refresh_declared(
170
+ conn,
171
+ apis: list[dict],
172
+ sitemap: str | None = None,
173
+ route_param_patterns: list[str] | None = None,
174
+ *,
175
+ updated_at: str = "",
176
+ ) -> dict:
177
+ """Refresh the whole declared surface and run the re-link pass.
178
+
179
+ ``apis`` entries are ``{"name": str, "openapi": <url-or-path>}``. Each
180
+ manifest source is replaced independently (``openapi:<name>`` /
181
+ ``sitemap``); triage flags and nodes are never touched.
182
+ """
183
+ summary: dict = {"apis": [], "sitemap": None, "relinked": 0}
184
+ for api in apis:
185
+ doc = parse_manifest_document(load_text(api["openapi"]), api["name"])
186
+ entries = parse_openapi_endpoints(doc, api["name"])
187
+ result = replace_declared_source(conn, f"openapi:{api['name']}", entries, updated_at=updated_at)
188
+ summary["apis"].append(result)
189
+ if sitemap:
190
+ entries = parse_sitemap_routes(load_text(sitemap), route_param_patterns)
191
+ summary["sitemap"] = replace_declared_source(conn, "sitemap", entries, updated_at=updated_at)
192
+ summary["relinked"] = relink_observed_endpoints(conn)
193
+ return summary