@qa-gentic/stlc-agents 1.0.27 → 1.0.28

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 (90) hide show
  1. package/ARCHITECTURE-ADO.md +350 -0
  2. package/ARCHITECTURE-JIRA.md +203 -0
  3. package/QUICKSTART-ADO.md +400 -0
  4. package/QUICKSTART-JIRA.md +334 -0
  5. package/README.md +49 -0
  6. package/package.json +18 -6
  7. package/skills/migrate-framework/SKILL.md +207 -0
  8. package/src/stlc_agents/__pycache__/__init__.cpython-313.pyc +0 -0
  9. package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-313.pyc +0 -0
  10. package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-313.pyc +0 -0
  11. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-313.pyc +0 -0
  13. package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-313.pyc +0 -0
  14. package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-313.pyc +0 -0
  15. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  16. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-313.pyc +0 -0
  17. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-313.pyc +0 -0
  18. package/src/stlc_agents/agent_jira_manager/__pycache__/__init__.cpython-313.pyc +0 -0
  19. package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-313.pyc +0 -0
  20. package/src/stlc_agents/agent_jira_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  21. package/src/stlc_agents/agent_jira_manager/tools/__pycache__/jira_workitem.cpython-313.pyc +0 -0
  22. package/src/stlc_agents/agent_migration/__init__.py +0 -0
  23. package/src/stlc_agents/agent_migration/__pycache__/__init__.cpython-313.pyc +0 -0
  24. package/src/stlc_agents/agent_migration/__pycache__/_migrate.cpython-313.pyc +0 -0
  25. package/src/stlc_agents/agent_migration/__pycache__/cli.cpython-313.pyc +0 -0
  26. package/src/stlc_agents/agent_migration/__pycache__/detector.cpython-313.pyc +0 -0
  27. package/src/stlc_agents/agent_migration/__pycache__/mapper.cpython-313.pyc +0 -0
  28. package/src/stlc_agents/agent_migration/__pycache__/reporter.cpython-313.pyc +0 -0
  29. package/src/stlc_agents/agent_migration/__pycache__/server.cpython-313.pyc +0 -0
  30. package/src/stlc_agents/agent_migration/_migrate.py +1398 -0
  31. package/src/stlc_agents/agent_migration/cli.py +217 -0
  32. package/src/stlc_agents/agent_migration/detector.py +81 -0
  33. package/src/stlc_agents/agent_migration/mapper.py +439 -0
  34. package/src/stlc_agents/agent_migration/reporter.py +86 -0
  35. package/src/stlc_agents/agent_migration/server.py +267 -0
  36. package/src/stlc_agents/agent_migration/transformer/__init__.py +0 -0
  37. package/src/stlc_agents/agent_migration/transformer/__pycache__/__init__.cpython-313.pyc +0 -0
  38. package/src/stlc_agents/agent_migration/transformer/__pycache__/config_merger.cpython-313.pyc +0 -0
  39. package/src/stlc_agents/agent_migration/transformer/__pycache__/healer_injector.cpython-313.pyc +0 -0
  40. package/src/stlc_agents/agent_migration/transformer/__pycache__/import_fixer.cpython-313.pyc +0 -0
  41. package/src/stlc_agents/agent_migration/transformer/__pycache__/js_to_ts.cpython-313.pyc +0 -0
  42. package/src/stlc_agents/agent_migration/transformer/__pycache__/local_var_hoister.cpython-313.pyc +0 -0
  43. package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_moderniser.cpython-313.pyc +0 -0
  44. package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_registrar.cpython-313.pyc +0 -0
  45. package/src/stlc_agents/agent_migration/transformer/__pycache__/spec_to_bdd.cpython-313.pyc +0 -0
  46. package/src/stlc_agents/agent_migration/transformer/config_merger.py +513 -0
  47. package/src/stlc_agents/agent_migration/transformer/healer_injector.py +1143 -0
  48. package/src/stlc_agents/agent_migration/transformer/import_fixer.py +419 -0
  49. package/src/stlc_agents/agent_migration/transformer/js_to_ts.py +413 -0
  50. package/src/stlc_agents/agent_migration/transformer/local_var_hoister.py +378 -0
  51. package/src/stlc_agents/agent_migration/transformer/locator_moderniser.py +132 -0
  52. package/src/stlc_agents/agent_migration/transformer/locator_registrar.py +328 -0
  53. package/src/stlc_agents/agent_migration/transformer/spec_to_bdd.py +820 -0
  54. package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-313.pyc +0 -0
  55. package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-313.pyc +0 -0
  56. package/src/stlc_agents/agent_playwright_generator/server.py +926 -91
  57. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  58. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-313.pyc +0 -0
  59. package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-313.pyc +0 -0
  61. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  62. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-313.pyc +0 -0
  63. package/src/stlc_agents/shared/__pycache__/__init__.cpython-313.pyc +0 -0
  64. package/src/stlc_agents/shared/__pycache__/auth.cpython-313.pyc +0 -0
  65. package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-313.pyc +0 -0
  66. package/src/stlc_agents/shared/__pycache__/pricing.cpython-313.pyc +0 -0
  67. package/src/stlc_agents/shared_jira/__pycache__/__init__.cpython-313.pyc +0 -0
  68. package/src/stlc_agents/shared_jira/__pycache__/auth.cpython-313.pyc +0 -0
  69. package/src/stlc_agents/webhook_orchestrator/__pycache__/__init__.cpython-313.pyc +0 -0
  70. package/src/stlc_agents/webhook_orchestrator/__pycache__/agent_runner.cpython-313.pyc +0 -0
  71. package/src/stlc_agents/webhook_orchestrator/__pycache__/models.cpython-313.pyc +0 -0
  72. package/src/stlc_agents/webhook_orchestrator/__pycache__/orchestrator.cpython-313.pyc +0 -0
  73. package/src/stlc_agents/webhook_orchestrator/__pycache__/webhook_bridge.cpython-313.pyc +0 -0
  74. package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-314.pyc +0 -0
  75. package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-314.pyc +0 -0
  76. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  77. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-314.pyc +0 -0
  78. package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-314.pyc +0 -0
  79. package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-314.pyc +0 -0
  80. package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-314.pyc +0 -0
  81. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  82. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-314.pyc +0 -0
  83. package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-314.pyc +0 -0
  84. package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-314.pyc +0 -0
  85. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  86. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-314.pyc +0 -0
  87. package/src/stlc_agents/shared/__pycache__/__init__.cpython-314.pyc +0 -0
  88. package/src/stlc_agents/shared/__pycache__/auth.cpython-314.pyc +0 -0
  89. package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-314.pyc +0 -0
  90. package/src/stlc_agents/shared/__pycache__/pricing.cpython-314.pyc +0 -0
@@ -0,0 +1,419 @@
1
+ """Update relative import paths to match Helix-QA directory structure.
2
+
3
+ The authoritative source for "where does source file X end up in Helix?" is
4
+ the mapper. When `_migrate.py` orchestrates the run it builds a lookup of
5
+ `{absolute_source_path → helix_target}` and passes it in via `source_to_target`.
6
+ The import fixer uses that lookup first — only falling back to heuristic
7
+ prediction (`_infer_helix_path`) when the imported file isn't in the mapping
8
+ (e.g. the import points to something outside the source tree).
9
+
10
+ This keeps imports byte-correct after disambiguation, custom mapper rules,
11
+ or any other mapper logic the fixer would otherwise have to mirror.
12
+ """
13
+ from __future__ import annotations
14
+ import re
15
+ from pathlib import Path, PurePosixPath
16
+
17
+
18
+ # Known path aliases used in Helix projects
19
+ _HELIX_ALIASES = {
20
+ re.compile(r"[Ll]ocator[Hh]ealer"): "@utils/locators/LocatorHealer",
21
+ re.compile(r"[Ll]ocator[Rr]epository"): "@utils/locators/LocatorRepository",
22
+ re.compile(r"[Tt]iming[Hh]ealer"): "@utils/locators/TimingHealer",
23
+ re.compile(r"[Vv]isual[Ii]ntent"): "@utils/locators/VisualIntentChecker",
24
+ re.compile(r"[Hh]ealing[Dd]ashboard"): "@utils/locators/HealingDashboard",
25
+ re.compile(r"[Bb]ase[Pp]age"): "./BasePage",
26
+ re.compile(r"pageFixture|PageFixture"): "@hooks/pageFixture",
27
+ }
28
+
29
+ # Rewrite source-project @alias/... paths to Helix equivalents.
30
+ # Locators and page objects that were migrated get their alias updated.
31
+ _SOURCE_ALIAS_RE = re.compile(
32
+ r"^(@pages/|@steps/|@features/)(.+?)(?:\.(ts|js))?$"
33
+ )
34
+
35
+
36
+ def _rewrite_source_alias(path: str) -> str | None:
37
+ """
38
+ Rewrite a source-project path alias to its Helix equivalent.
39
+ e.g. '@pages/amplify/ui/locators' → '@locators/ui-locators.locators'
40
+ '@pages/amplify/ui/login.page' → '@pages/login.page'
41
+ '@pages/nova/locators' → '@locators/nova-locators.locators'
42
+ Returns None if no rewrite is needed or possible.
43
+ """
44
+ m = _SOURCE_ALIAS_RE.match(path)
45
+ if not m:
46
+ return None
47
+
48
+ _, tail, _ext = m.groups()
49
+ # `name` is the last path segment, possibly including role suffix like "editModule.page"
50
+ name = Path(tail).name
51
+
52
+ from stlc_agents.agent_migration.mapper import _to_kebab, _feature_kebab
53
+
54
+ # Locator files (bare "locators" or "novaLocators")
55
+ if re.search(r"[Ll]ocators?$", name):
56
+ base = re.sub(r"[Ll]ocators?$", "", name)
57
+ if not base:
58
+ # Bare "locators" file — use parent directory name as prefix
59
+ parent = Path(tail).parent.name.lower()
60
+ return f"@locators/{parent}-locators.locators"
61
+ return f"@locators/{_to_kebab(base)}-locators.locators"
62
+
63
+ # Page objects — name ends with ".page" (no extension) or stem ends with "Page"
64
+ if re.search(r"\.page$", name):
65
+ base = re.sub(r"\.page$", "", name)
66
+ return f"@pages/{_to_kebab(base)}.page"
67
+ if re.search(r"[Pp]age$", Path(name).stem):
68
+ base = re.sub(r"[Pp]age$", "", Path(name).stem)
69
+ return f"@pages/{_to_kebab(base)}.page"
70
+
71
+ # Step definitions
72
+ if re.search(r"\.steps?$", name):
73
+ base = re.sub(r"\.steps?$", "", name)
74
+ return f"@steps/{_to_kebab(base)}.steps"
75
+ if re.search(r"[Ss]teps?$", Path(name).stem):
76
+ base = re.sub(r"[Ss]teps?$", "", Path(name).stem)
77
+ return f"@steps/{_to_kebab(base)}.steps"
78
+
79
+ # API clients — anything ending in `.api` lives under @helper/api/ in helix-qa
80
+ if re.search(r"\.api$", name):
81
+ return f"@helper/api/{name}"
82
+
83
+ return None
84
+
85
+
86
+ def fix_imports(
87
+ content: str,
88
+ source_path: str,
89
+ target_path: str,
90
+ source_root: str,
91
+ source_to_target: dict[str, str] | None = None,
92
+ ) -> tuple[str, list[str]]:
93
+ """
94
+ Update import paths in content for the new Helix target location.
95
+
96
+ source_path — absolute path of the original file
97
+ target_path — Helix-relative target path (e.g. 'src/pages/login.page.ts')
98
+ source_root — absolute path of the source project root
99
+ source_to_target — authoritative {absolute_source_path → helix_target_rel}
100
+ lookup from the mapper. When the imported file is
101
+ present here, its target is used verbatim instead of
102
+ being predicted by the heuristic. Pass `None` (or omit)
103
+ to fall back to the legacy guess-only behaviour.
104
+ Returns (updated_content, changes).
105
+ """
106
+ changes: list[str] = []
107
+ lines = content.split("\n")
108
+ result = []
109
+ s2t = source_to_target or {}
110
+
111
+ for line in lines:
112
+ new_line = _fix_line(line, source_path, target_path, source_root, s2t, changes)
113
+ result.append(new_line)
114
+
115
+ return "\n".join(result), changes
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Per-line processing
120
+ # ---------------------------------------------------------------------------
121
+
122
+ _IMPORT_FROM_RE = re.compile(
123
+ r"^(\s*(?:import|export)\s+.*?\s+from\s+)(['\"])(\.[^'\"]+)\2(.*)"
124
+ )
125
+ # Match absolute alias imports on a single line: import Foo from '@alias/...'
126
+ _IMPORT_ALIAS_RE = re.compile(
127
+ r"^(\s*(?:import|export)\s+.*?\s+from\s+)(['\"])(@[^'\"]+)\2(.*)"
128
+ )
129
+ # Match closing line of a multi-line import: } from '@alias/...'
130
+ _IMPORT_ALIAS_CLOSE_RE = re.compile(
131
+ r"^(\s*\}[ \t]+from\s+)(['\"])(@[^'\"]+)\2(.*)"
132
+ )
133
+ _REQUIRE_RE = re.compile(r"(require\s*\(\s*)(['\"])(\.[^'\"]+)\2(\s*\))")
134
+
135
+
136
+ def _fix_line(
137
+ line: str,
138
+ source_path: str,
139
+ target_path: str,
140
+ source_root: str,
141
+ source_to_target: dict[str, str],
142
+ changes: list[str],
143
+ ) -> str:
144
+ # ES import/export from '...' (relative paths)
145
+ m = _IMPORT_FROM_RE.match(line)
146
+ if m:
147
+ prefix, quote, rel_path, suffix = m.groups()
148
+ new_path = _remap(rel_path, source_path, target_path, source_root, source_to_target, changes)
149
+ if new_path != rel_path:
150
+ return f"{prefix}{quote}{new_path}{quote}{suffix}"
151
+ return line
152
+
153
+ # ES import/export from '@alias/...' (absolute alias paths from source project)
154
+ m = _IMPORT_ALIAS_RE.match(line) or _IMPORT_ALIAS_CLOSE_RE.match(line)
155
+ if m:
156
+ prefix, quote, alias_path, suffix = m.groups()
157
+ new_path = _rewrite_source_alias_via_mapping(
158
+ alias_path, source_root, source_to_target,
159
+ )
160
+ if new_path is None:
161
+ new_path = _rewrite_source_alias(alias_path)
162
+ if new_path and new_path != alias_path:
163
+ changes.append(f"Remapped alias {alias_path!r} → {new_path!r}")
164
+ return f"{prefix}{quote}{new_path}{quote}{suffix}"
165
+ return line
166
+
167
+ # CommonJS require('...')
168
+ m = _REQUIRE_RE.search(line)
169
+ if m:
170
+ req_prefix, quote, rel_path, req_suffix = m.groups()
171
+ new_path = _remap(rel_path, source_path, target_path, source_root, source_to_target, changes)
172
+ if new_path != rel_path:
173
+ return line.replace(f"{quote}{rel_path}{quote}", f"{quote}{new_path}{quote}", 1)
174
+
175
+ return line
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Path remapping
180
+ # ---------------------------------------------------------------------------
181
+
182
+ def _remap(
183
+ rel_path: str,
184
+ source_path: str,
185
+ target_path: str,
186
+ source_root: str,
187
+ source_to_target: dict[str, str],
188
+ changes: list[str],
189
+ ) -> str:
190
+ # Check against known infrastructure aliases first
191
+ for pattern, alias in _HELIX_ALIASES.items():
192
+ if pattern.search(rel_path):
193
+ changes.append(f"Remapped {rel_path!r} → {alias!r} (alias)")
194
+ return alias
195
+
196
+ # Resolve the import target relative to the source file
197
+ src_dir = Path(source_path).parent
198
+ raw_target = (src_dir / rel_path).resolve()
199
+ root = Path(source_root).resolve()
200
+
201
+ try:
202
+ rel_to_root = raw_target.relative_to(root).as_posix()
203
+ except ValueError:
204
+ return rel_path # escapes source root — leave as-is
205
+
206
+ # Authoritative path: ask the mapper. We need to match against a file with
207
+ # an extension (the import didn't include one); try `.ts`, `.tsx`, `.js`,
208
+ # `.jsx` and bare-path index.* variants.
209
+ helix_target = _lookup_mapped_target(raw_target, source_to_target)
210
+
211
+ # Fallback to heuristic prediction when the imported file isn't a mapped
212
+ # source file (e.g. import of a generated file, or an external module
213
+ # masquerading as a relative path).
214
+ if helix_target is None:
215
+ helix_target = _infer_helix_path(rel_to_root)
216
+ if helix_target is None:
217
+ return rel_path
218
+
219
+ new_rel = _relative_helix_import(target_path, helix_target)
220
+ if new_rel != rel_path:
221
+ changes.append(f"Remapped import {rel_path!r} → {new_rel!r}")
222
+ return new_rel
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Mapper lookup — converts the resolved source path of an imported file into
227
+ # the helix target the mapper actually wrote (or will write). The import in
228
+ # the source code typically omits the extension, so we probe the common ones.
229
+ # ---------------------------------------------------------------------------
230
+
231
+ _IMPORT_PROBE_SUFFIXES = (".ts", ".tsx", ".js", ".jsx", "")
232
+
233
+
234
+ def _lookup_mapped_target(raw_target: Path, s2t: dict[str, str]) -> str | None:
235
+ """Return the helix target for `raw_target` if the mapper covers it."""
236
+ if not s2t:
237
+ return None
238
+ base = str(raw_target)
239
+ for suffix in _IMPORT_PROBE_SUFFIXES:
240
+ candidate = base + suffix
241
+ if candidate in s2t:
242
+ return _strip_ext(s2t[candidate])
243
+ # The import might point at a directory with an index file inside.
244
+ for index_name in ("index.ts", "index.tsx", "index.js", "index.jsx"):
245
+ candidate = str(raw_target / index_name)
246
+ if candidate in s2t:
247
+ return _strip_ext(s2t[candidate])
248
+ return None
249
+
250
+
251
+ def _strip_ext(target_rel: str) -> str:
252
+ """Drop the file extension from a helix target path for import use."""
253
+ for suffix in (".ts", ".tsx", ".js", ".jsx"):
254
+ if target_rel.endswith(suffix):
255
+ return target_rel[: -len(suffix)]
256
+ return target_rel
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Alias-via-mapping: resolve a source-project alias (e.g. `@pages/scweb/locators`)
261
+ # by reconstructing the absolute source path and looking it up in the mapping.
262
+ # Falls back to the legacy heuristic when the alias prefix isn't recognised.
263
+ # ---------------------------------------------------------------------------
264
+
265
+ # Map common source-project alias prefixes to their physical sub-paths inside
266
+ # the source tree. Most projects use `@pages/X` for `src/pages/X.ts`, etc.
267
+ # Unknown aliases fall through to the heuristic rewriter.
268
+ _ALIAS_SUBPATHS = {
269
+ "@pages": ("src/pages", "pages"),
270
+ "@steps": ("src/test/steps", "src/steps", "steps"),
271
+ "@features": ("src/test/features", "src/features", "features"),
272
+ "@locators": ("src/locators", "locators"),
273
+ "@helper": ("src/helper", "src/helpers", "helper", "helpers"),
274
+ "@hooks": ("src/hooks", "hooks"),
275
+ }
276
+
277
+
278
+ def _rewrite_source_alias_via_mapping(
279
+ alias_path: str,
280
+ source_root: str,
281
+ s2t: dict[str, str],
282
+ ) -> str | None:
283
+ """
284
+ Try to resolve `@alias/sub/path` by probing physical sub-paths in the
285
+ source tree and looking the result up in the mapper's table.
286
+ """
287
+ if not s2t or not alias_path.startswith("@"):
288
+ return None
289
+ head, _, tail = alias_path.partition("/")
290
+ if not tail:
291
+ return None
292
+ subpaths = _ALIAS_SUBPATHS.get(head)
293
+ if not subpaths:
294
+ return None
295
+ root = Path(source_root).resolve()
296
+ for sub in subpaths:
297
+ for suffix in _IMPORT_PROBE_SUFFIXES:
298
+ candidate = root / sub / (tail + suffix)
299
+ mapped = s2t.get(str(candidate.resolve()))
300
+ if mapped:
301
+ return _helix_target_to_alias(_strip_ext(mapped))
302
+ # Also try directory index lookup
303
+ for index_name in ("index.ts", "index.tsx", "index.js", "index.jsx"):
304
+ candidate = root / sub / tail / index_name
305
+ mapped = s2t.get(str(candidate.resolve()))
306
+ if mapped:
307
+ return _helix_target_to_alias(_strip_ext(mapped))
308
+ return None
309
+
310
+
311
+ def _helix_target_to_alias(helix_rel: str) -> str:
312
+ """
313
+ Convert a helix-relative file path (no extension) into the most natural
314
+ `@alias/...` form so callers can import via the configured tsconfig path
315
+ aliases.
316
+ """
317
+ mapping = (
318
+ ("src/locators/", "@locators/"),
319
+ ("src/pages/", "@pages/"),
320
+ ("src/test/steps/", "@steps/"),
321
+ ("src/test/features/","@features/"),
322
+ ("src/hooks/", "@hooks/"),
323
+ ("src/helper/", "@helper/"),
324
+ ("src/utils/", "@utils/"),
325
+ )
326
+ for prefix, alias in mapping:
327
+ if helix_rel.startswith(prefix):
328
+ return alias + helix_rel[len(prefix):]
329
+ return helix_rel
330
+
331
+
332
+ def _infer_helix_path(rel_to_root: str) -> str | None:
333
+ """Guess the Helix file path from a source-root-relative path."""
334
+ name = Path(rel_to_root).stem
335
+
336
+ if re.search(r"[Ll]ocators?$", name):
337
+ from stlc_agents.agent_migration.mapper import _to_kebab # type: ignore[import]
338
+ stem = re.sub(r"[Ll]ocators?$", "", name)
339
+ if not stem:
340
+ # File is literally "locators.ts" — disambiguation would have added
341
+ # the parent directory name as prefix, e.g. ui/ → ui-locators.locators
342
+ parent = Path(rel_to_root).parent.name.lower()
343
+ return f"src/locators/{parent}-locators.locators"
344
+ return f"src/locators/{_to_kebab(stem)}.locators"
345
+
346
+ # Page-object import patterns:
347
+ # `Xxx.page` (stem = "Xxx.page", or "Xxx" after one strip)
348
+ # `XxxPage` (PascalCase suffix)
349
+ rel_path = rel_to_root
350
+ if rel_path.endswith(".page") or re.search(r"\.page(\.[jt]s)?$", rel_path):
351
+ from stlc_agents.agent_migration.mapper import _to_kebab # type: ignore[import]
352
+ # Get the bare class name: ".../Foo.page" → "Foo"
353
+ base = Path(rel_path).name
354
+ base = re.sub(r"\.[jt]s$", "", base)
355
+ base = re.sub(r"\.page$", "", base, flags=re.IGNORECASE)
356
+ return f"src/pages/{_to_kebab(base)}.page"
357
+ if re.search(r"[Pp]age$", name):
358
+ from stlc_agents.agent_migration.mapper import _to_kebab # type: ignore[import]
359
+ stem = re.sub(r"[Pp]age$", "", name)
360
+ return f"src/pages/{_to_kebab(stem)}.page"
361
+
362
+ if rel_path.endswith(".steps") or re.search(r"\.steps?(\.[jt]s)?$", rel_path):
363
+ from stlc_agents.agent_migration.mapper import _to_kebab # type: ignore[import]
364
+ base = Path(rel_path).name
365
+ base = re.sub(r"\.[jt]s$", "", base)
366
+ base = re.sub(r"\.steps?$", "", base, flags=re.IGNORECASE)
367
+ return f"src/test/steps/{_to_kebab(base)}.steps"
368
+ if re.search(r"[Ss]teps?$", name):
369
+ from stlc_agents.agent_migration.mapper import _to_kebab # type: ignore[import]
370
+ stem = re.sub(r"[Ss]teps?$", "", name)
371
+ return f"src/test/steps/{_to_kebab(stem)}.steps"
372
+
373
+ # Helper / util files (anywhere under `helpers/` or `utils/`). The mapper
374
+ # routes these to `src/helper/<preserved-subpath>` — match its behavior so
375
+ # imports get rewritten to the new flat location instead of the legacy
376
+ # source-relative path.
377
+ m = re.match(r"^(?:helpers?|utils?)/(.+)$", rel_to_root)
378
+ if m:
379
+ sub = m.group(1).rsplit(".", 1)[0] # drop extension
380
+ return f"src/helper/{sub}"
381
+
382
+ # Fallback for files at the source root (no directory prefix). Things like
383
+ # `global-cleanup.ts`, `global-variableSetup.ts` are raw-copied to the
384
+ # helix root and keep their original name — recomputing the relative path
385
+ # against the new file location is still needed.
386
+ if "/" not in rel_to_root:
387
+ return rel_to_root.rsplit(".", 1)[0] # drop extension; helix-root-relative
388
+
389
+ return None
390
+
391
+
392
+ def _relative_helix_import(from_helix: str, to_helix: str) -> str:
393
+ """Compute a relative import path between two Helix-relative paths."""
394
+ if to_helix.startswith("@"):
395
+ return to_helix
396
+
397
+ from_dir = PurePosixPath(from_helix).parent
398
+ to_path = PurePosixPath(to_helix)
399
+
400
+ try:
401
+ rel = to_path.relative_to(from_dir)
402
+ return "./" + str(rel)
403
+ except ValueError:
404
+ pass
405
+
406
+ # Walk up from from_dir until we find the common ancestor
407
+ from_parts = from_dir.parts
408
+ to_parts = to_path.parts
409
+
410
+ common = 0
411
+ for f, t in zip(from_parts, to_parts):
412
+ if f == t:
413
+ common += 1
414
+ else:
415
+ break
416
+
417
+ up = len(from_parts) - common
418
+ down = to_parts[common:]
419
+ return "../" * up + "/".join(down)