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,178 @@
1
+ """Render the gap report: deterministic JSON, a shields badge, a human summary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ _MAX_ROWS = 50
8
+
9
+ #: shields.io endpoint colours, coarsest-first. Deliberately the palette a
10
+ #: README already uses for test/coverage badges, so a Steppy badge sits in a row
11
+ #: of them without looking foreign.
12
+ _BADGE_COLORS = ((50, "red"), (70, "orange"), (85, "yellow"), (95, "green"))
13
+ _BADGE_COLOR_BEST = "brightgreen"
14
+ _BADGE_COLOR_UNKNOWN = "lightgrey"
15
+
16
+ DEFAULT_BADGE_LABEL = "e2e surface"
17
+
18
+
19
+ def gaps_to_json(gaps: dict) -> str:
20
+ """Machine-readable report: sorted keys, no wall clock — diffable."""
21
+ return json.dumps(gaps, indent=2, sort_keys=True)
22
+
23
+
24
+ def badge_color(percent: int) -> str:
25
+ """Shields colour for a percentage."""
26
+ for ceiling, color in _BADGE_COLORS:
27
+ if percent < ceiling:
28
+ return color
29
+ return _BADGE_COLOR_BEST
30
+
31
+
32
+ def badge_payload(gaps: dict, *, surface: str = "all", label: str = DEFAULT_BADGE_LABEL) -> dict:
33
+ """A shields.io **endpoint** object for the e2e surface ratio.
34
+
35
+ An endpoint JSON rather than a generated SVG: it inherits whatever style the
36
+ rest of the README's badge row uses, it is cacheable by shields, and no image
37
+ rendering lands in this repo. Consumed as
38
+ ``https://img.shields.io/endpoint?url=<raw-url-to-this-file>``.
39
+
40
+ Nothing declared ⇒ ``"n/a"`` in grey. A badge reading ``0%`` when the truth is
41
+ "no OpenAPI surface is configured" is worse than one admitting it does not
42
+ know, because the first invites someone to go fix a number that is not broken.
43
+ """
44
+ ratio = (gaps.get("e2e_surface") or {}).get(surface) or {}
45
+ percent = ratio.get("percent")
46
+ if percent is None:
47
+ return {"schemaVersion": 1, "label": label, "message": "n/a", "color": _BADGE_COLOR_UNKNOWN}
48
+ return {
49
+ "schemaVersion": 1,
50
+ "label": label,
51
+ "message": f"{percent}%",
52
+ "color": badge_color(percent),
53
+ }
54
+
55
+
56
+ def badge_to_json(gaps: dict, *, surface: str = "all", label: str = DEFAULT_BADGE_LABEL) -> str:
57
+ """:func:`badge_payload` as deterministic JSON text."""
58
+ return json.dumps(badge_payload(gaps, surface=surface, label=label), indent=2, sort_keys=True) + "\n"
59
+
60
+
61
+ def _print_bucket_table(console, title: str, rows: list[dict], columns: list[str]) -> None:
62
+ from rich.table import Table
63
+
64
+ table = Table(title=f"{title} ({len(rows)})", title_justify="left")
65
+ for col in columns:
66
+ table.add_column(col)
67
+ for row in rows[:_MAX_ROWS]:
68
+ table.add_row(*(str(row.get(col, "")) for col in columns))
69
+ console.print(table)
70
+ if len(rows) > _MAX_ROWS:
71
+ console.print(f"[dim]… and {len(rows) - _MAX_ROWS} more (see --json output)[/dim]")
72
+
73
+
74
+ def _count_sources(entries: list[dict]) -> list[tuple[str, int]]:
75
+ """``[(source, count)]`` sorted for stable output."""
76
+ tally: dict[str, int] = {}
77
+ for entry in entries:
78
+ source = entry.get("triage_source", "")
79
+ tally[source] = tally.get(source, 0) + 1
80
+ return sorted(tally.items())
81
+
82
+
83
+ def _print_surface(gaps: dict, console) -> None:
84
+ """The one line the badge is derived from, so the two can never disagree."""
85
+ ratio = (gaps.get("e2e_surface") or {}).get("all") or {}
86
+ percent = ratio.get("percent")
87
+ if percent is None:
88
+ console.print(
89
+ "[bold]E2E surface[/bold]: not measured — nothing declared. "
90
+ "Configure [bold]coverage.apis[/bold] (and optionally [bold]coverage.sitemap[/bold]), "
91
+ "then run [bold]steppy coverage refresh[/bold].\n"
92
+ )
93
+ return
94
+ console.print(
95
+ f"[bold]E2E surface[/bold]: {percent}% "
96
+ f"({ratio['tested']}/{ratio['declared']} declared endpoints + routes a UI test could reach)\n"
97
+ )
98
+
99
+
100
+ def print_summary(gaps: dict, console) -> None:
101
+ """Short human summary: counts first, then the bucket lists."""
102
+ counts = gaps["counts"]
103
+ ep = counts["endpoints"]
104
+ console.print(
105
+ f"[bold]Endpoints[/bold]: {ep['declared']} declared | "
106
+ f"{ep['tested']} tested ({ep['tested_strong']} strong) | "
107
+ f"[red]{ep['declared_unreached']} declared-unreached[/red] "
108
+ f"({ep['declared_unreached_actionable']} actionable, rest non_ui) | "
109
+ f"{ep['discovered_uncovered']} discovered-uncovered | "
110
+ f"{ep['unclassified']} unclassified"
111
+ )
112
+ rt = counts["routes"]
113
+ console.print(
114
+ f"[bold]Routes[/bold]: {rt['declared']} declared | {rt['tested']} tested | "
115
+ f"[red]{rt['declared_unreached']} declared-unreached[/red] | "
116
+ f"{rt['discovered_uncovered']} discovered-uncovered"
117
+ )
118
+ console.print(
119
+ f"[bold]Modules[/bold]: {counts['modules']['covered']} covered | scenarios in map: {counts['scenarios']}"
120
+ )
121
+ _print_surface(gaps, console)
122
+
123
+ unreached = gaps["endpoints"]["declared_unreached"]
124
+ actionable = [e for e in unreached if e["triage"] != "non_ui"]
125
+ if actionable:
126
+ _print_bucket_table(
127
+ console,
128
+ "Declared but unreached (actionable)",
129
+ actionable,
130
+ ["api", "method", "key", "triage", "note"],
131
+ )
132
+ non_ui = [e for e in unreached if e["triage"] == "non_ui"]
133
+ if non_ui:
134
+ by_source = _count_sources(non_ui)
135
+ detail = ", ".join(f"{n} from {src or 'unspecified'}" for src, n in by_source)
136
+ console.print(f"[dim]{len(non_ui)} declared-unreached triaged non_ui, hidden ({detail})[/dim]")
137
+
138
+ if gaps["endpoints"]["discovered_uncovered"]:
139
+ _print_bucket_table(
140
+ console,
141
+ "Discovered but weakly covered (endpoints)",
142
+ gaps["endpoints"]["discovered_uncovered"],
143
+ ["api", "method", "key", "scenarios"],
144
+ )
145
+ if gaps["routes"]["declared_unreached"]:
146
+ _print_bucket_table(
147
+ console,
148
+ "Declared but unreached (routes)",
149
+ gaps["routes"]["declared_unreached"],
150
+ ["key", "triage", "note"],
151
+ )
152
+ if gaps["routes"]["discovered_uncovered"]:
153
+ _print_bucket_table(
154
+ console,
155
+ "Discovered but weakly covered (routes)",
156
+ gaps["routes"]["discovered_uncovered"],
157
+ ["key", "scenarios"],
158
+ )
159
+ if gaps["endpoints"]["unclassified"]:
160
+ console.print(
161
+ f"[yellow]{len(gaps['endpoints']['unclassified'])} unclassified API request "
162
+ "pattern(s)[/yellow] — requests matching no configured coverage.apis base_urls; "
163
+ "check base_urls (include internal_url origins for parallel runs)."
164
+ )
165
+ if gaps["endpoints"]["discovered_tested"]:
166
+ console.print(
167
+ f"[yellow]{len(gaps['endpoints']['discovered_tested'])} tested endpoint(s) missing "
168
+ "from the manifest[/yellow] — stale spec or base-URL prefix mismatch; "
169
+ "run `steppy coverage refresh`."
170
+ )
171
+
172
+ declared_total = ep["declared"]
173
+ if declared_total and ep["tested"] == 0:
174
+ console.print(
175
+ "[yellow]No declared endpoint was ever matched by an observed request — "
176
+ "if scenarios did run, coverage.apis base_urls probably don't mirror the "
177
+ "OpenAPI servers[].url prefix.[/yellow]"
178
+ )
@@ -0,0 +1,219 @@
1
+ """Source-map decoding: executed V8 offsets -> source modules. Pure Python.
2
+
3
+ The recorder (generated conftest) persists, per script: the executed function
4
+ start offsets (V8 character offsets), a ``line_starts`` newline-offset table
5
+ computed from the script source, and a reference to the source map (inline
6
+ ``data:`` maps are decoded + stored at record time; external maps are looked
7
+ up on local disk at merge time via ``coverage.sourcemap_dir``).
8
+
9
+ This module turns those into source-module sets:
10
+
11
+ offset --(bisect over line_starts)--> (generated line, col)
12
+ --(floor lookup in the decoded mappings line index)--> sources[idx]
13
+ --(webpack:// / sourceRoot / dot-segment normalization, ignore
14
+ filter)--> module key
15
+
16
+ Degradation ladder (per script, never an error): usable map -> module keys;
17
+ unusable/missing map or missing line_starts -> a single ``bundle:<url-path>``
18
+ node so the signal survives at bundle granularity.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import gzip
24
+ import json
25
+ import posixpath
26
+ import re
27
+ from bisect import bisect_right
28
+ from pathlib import Path
29
+ from urllib.parse import urlsplit
30
+
31
+ _B64 = {c: i for i, c in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")}
32
+ _SCHEME_RE = re.compile(r"^[a-zA-Z][\w+.\-]*://")
33
+
34
+
35
+ class SourceMapUnusable(Exception):
36
+ """Structural problem with a map — caller degrades to bundle granularity."""
37
+
38
+
39
+ def _decode_vlq_segment(segment: str) -> list[int]:
40
+ values: list[int] = []
41
+ result = shift = 0
42
+ for ch in segment:
43
+ try:
44
+ digit = _B64[ch]
45
+ except KeyError:
46
+ raise SourceMapUnusable(f"invalid VLQ character {ch!r}") from None
47
+ result |= (digit & 31) << shift
48
+ if digit & 32:
49
+ shift += 5
50
+ else:
51
+ values.append(-(result >> 1) if result & 1 else result >> 1)
52
+ result = shift = 0
53
+ if shift:
54
+ raise SourceMapUnusable("truncated VLQ segment")
55
+ return values
56
+
57
+
58
+ def build_line_index(mappings: str) -> list[tuple[list[int], list[int]]]:
59
+ """Decode ``mappings`` into per-generated-line ``(gen_cols, src_idxs)``.
60
+
61
+ ``gen_col`` resets per line; ``src_idx`` accumulates across lines (spec
62
+ behaviour). Segments without a source field get ``src_idx = -1``.
63
+ """
64
+ lines: list[tuple[list[int], list[int]]] = []
65
+ src_idx = 0
66
+ for line_str in mappings.split(";"):
67
+ cols: list[int] = []
68
+ idxs: list[int] = []
69
+ gen_col = 0
70
+ for seg in line_str.split(","):
71
+ if not seg:
72
+ continue
73
+ fields = _decode_vlq_segment(seg)
74
+ gen_col += fields[0]
75
+ if len(fields) >= 4:
76
+ src_idx += fields[1]
77
+ cols.append(gen_col)
78
+ idxs.append(src_idx)
79
+ else:
80
+ cols.append(gen_col)
81
+ idxs.append(-1)
82
+ lines.append((cols, idxs))
83
+ return lines
84
+
85
+
86
+ def normalize_source_path(source: str, source_root: str = "") -> str:
87
+ """Normalize a source-map ``sources`` entry to a stable module key.
88
+
89
+ Resolves against ``sourceRoot``, strips scheme prefixes (``webpack://``,
90
+ ``ng://`` …) and leading slashes, and collapses dot segments. Webpack
91
+ namespace components (``webpack://my-app/src/…``) are kept — they are
92
+ stable across runs and can be ignored via ``coverage.module_ignore``.
93
+ """
94
+ s = source
95
+ if source_root:
96
+ root = source_root if source_root.endswith("/") else source_root + "/"
97
+ if not _SCHEME_RE.match(s) and not s.startswith("/"):
98
+ s = root + s
99
+ m = _SCHEME_RE.match(s)
100
+ if m:
101
+ s = s[m.end() :]
102
+ s = s.lstrip("/")
103
+ if s:
104
+ s = posixpath.normpath(s)
105
+ if s == ".":
106
+ s = ""
107
+ return s
108
+
109
+
110
+ def is_ignored(module: str, ignore: tuple[str, ...]) -> bool:
111
+ """True when ``module`` falls under any ignore entry (prefix or any path level)."""
112
+ for entry in ignore:
113
+ clean = entry.rstrip("/")
114
+ if not clean:
115
+ continue
116
+ if module == clean or module.startswith(clean + "/"):
117
+ return True
118
+ if f"/{clean}/" in f"/{module}":
119
+ return True
120
+ return False
121
+
122
+
123
+ def modules_for_script(
124
+ executed_offsets: list[int],
125
+ line_starts: list[int],
126
+ map_obj: dict,
127
+ ignore: tuple[str, ...] = (),
128
+ ) -> set[str]:
129
+ """Map executed character offsets to the set of executed source modules.
130
+
131
+ Raises :class:`SourceMapUnusable` on structural problems (index maps,
132
+ missing sources, malformed VLQ) so the caller can fall back to bundle
133
+ granularity. Offsets that hit no mapping segment are silently skipped.
134
+ """
135
+ if not isinstance(map_obj, dict):
136
+ raise SourceMapUnusable("map is not a JSON object")
137
+ if "sections" in map_obj:
138
+ raise SourceMapUnusable("index maps (sections) not supported")
139
+ sources = map_obj.get("sources") or []
140
+ if not sources:
141
+ raise SourceMapUnusable("map has no sources")
142
+ if not line_starts:
143
+ raise SourceMapUnusable("missing line_starts")
144
+ source_root = map_obj.get("sourceRoot") or ""
145
+ normalized = [normalize_source_path(s or "", source_root) for s in sources]
146
+ line_index = build_line_index(map_obj.get("mappings") or "")
147
+
148
+ modules: set[str] = set()
149
+ for offset in executed_offsets:
150
+ line = bisect_right(line_starts, offset) - 1
151
+ if line < 0 or line >= len(line_index):
152
+ continue
153
+ col = offset - line_starts[line]
154
+ cols, idxs = line_index[line]
155
+ i = bisect_right(cols, col) - 1
156
+ if i < 0:
157
+ continue
158
+ idx = idxs[i]
159
+ if 0 <= idx < len(normalized):
160
+ module = normalized[idx]
161
+ if module and not is_ignored(module, ignore):
162
+ modules.add(module)
163
+ return modules
164
+
165
+
166
+ def bundle_key(script_url: str) -> str:
167
+ """Fallback module key when no usable map exists for a script."""
168
+ path = urlsplit(script_url).path
169
+ return f"bundle:{path or script_url}"
170
+
171
+
172
+ def load_map_for_entry(
173
+ entry: dict,
174
+ maps_dir: Path | None,
175
+ sourcemap_dir: Path | None,
176
+ ) -> dict | None:
177
+ """Resolve the source map for one recorded script entry.
178
+
179
+ Order: inline blob persisted by the recorder (``_maps/<sha256>.json.gz``)
180
+ -> local-disk lookup under ``sourcemap_dir`` (by sourceMappingURL
181
+ basename, then ``<script-basename>.map``; recursive, lexicographically
182
+ first match) -> ``None`` (bundle fallback).
183
+ """
184
+ map_info = entry.get("map") or {}
185
+ if map_info.get("kind") == "inline" and map_info.get("hash") and maps_dir is not None:
186
+ blob = Path(maps_dir) / f"{map_info['hash']}.json.gz"
187
+ if blob.is_file():
188
+ try:
189
+ return json.loads(gzip.decompress(blob.read_bytes()).decode("utf-8"))
190
+ except (OSError, ValueError):
191
+ return None
192
+
193
+ if sourcemap_dir is None:
194
+ return None
195
+ sourcemap_dir = Path(sourcemap_dir)
196
+ if not sourcemap_dir.is_dir():
197
+ return None
198
+
199
+ candidates: list[str] = []
200
+ if map_info.get("kind") == "external" and map_info.get("url"):
201
+ name = posixpath.basename(urlsplit(map_info["url"]).path)
202
+ if name:
203
+ candidates.append(name)
204
+ script_name = posixpath.basename(urlsplit(entry.get("url") or "").path)
205
+ if script_name:
206
+ candidates.append(script_name + ".map")
207
+
208
+ seen: set[str] = set()
209
+ for name in candidates:
210
+ if name in seen:
211
+ continue
212
+ seen.add(name)
213
+ matches = sorted(sourcemap_dir.rglob(name), key=lambda p: p.as_posix())
214
+ for match in matches:
215
+ try:
216
+ return json.loads(match.read_text(encoding="utf-8"))
217
+ except (OSError, ValueError):
218
+ continue
219
+ return None