@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,513 @@
1
+ """Merge source playwright/cucumber config values into Helix equivalents."""
2
+ from __future__ import annotations
3
+ import json
4
+ import re
5
+ import textwrap
6
+ from pathlib import Path
7
+
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Playwright config
11
+ # ---------------------------------------------------------------------------
12
+
13
+ def merge_playwright_config(source_content: str, helix_root: str) -> dict:
14
+ """
15
+ Extract key settings from a source playwright.config.js/ts and merge
16
+ them into the Helix playwright.config.ts.
17
+
18
+ Returns { content: str, target: str, changes: list[str] }
19
+ """
20
+ settings = _extract_playwright_settings(source_content)
21
+ helix_cfg = Path(helix_root) / "playwright.config.ts"
22
+
23
+ if helix_cfg.exists():
24
+ existing = helix_cfg.read_text(encoding="utf-8")
25
+ merged, changes = _merge_pw_into_existing(existing, settings)
26
+ else:
27
+ merged = _generate_pw_config(settings)
28
+ changes = ["Generated new playwright.config.ts from source settings"]
29
+
30
+ return {"content": merged, "target": "playwright.config.ts", "changes": changes}
31
+
32
+
33
+ def _extract_playwright_settings(cfg: str) -> dict:
34
+ settings: dict = {}
35
+
36
+ _find = lambda pattern: (re.search(pattern, cfg) or None) and re.search(pattern, cfg).group(1)
37
+
38
+ raw_url = _find(r"baseURL\s*:\s*['\"]([^'\"]+)['\"]")
39
+ if raw_url:
40
+ settings["baseURL"] = raw_url
41
+
42
+ raw_timeout = _find(r"\btimeout\s*:\s*(\d+)")
43
+ if raw_timeout:
44
+ settings["timeout"] = int(raw_timeout)
45
+
46
+ raw_retries = _find(r"\bretries\s*:\s*(\d+)")
47
+ if raw_retries:
48
+ settings["retries"] = int(raw_retries)
49
+
50
+ raw_workers = _find(r"\bworkers\s*:\s*(\d+)")
51
+ if raw_workers:
52
+ settings["workers"] = int(raw_workers)
53
+
54
+ for k in ("screenshot", "video", "trace"):
55
+ m = re.search(rf"\b{k}\s*:\s*['\"]([^'\"]+)['\"]", cfg)
56
+ if m:
57
+ settings[k] = m.group(1)
58
+
59
+ return settings
60
+
61
+
62
+ def _merge_pw_into_existing(existing: str, settings: dict) -> tuple[str, list[str]]:
63
+ changes: list[str] = []
64
+ result = existing
65
+
66
+ for key, value in settings.items():
67
+ if re.search(rf"\b{re.escape(key)}\s*:", result):
68
+ continue # already present
69
+
70
+ val_str = f"'{value}'" if isinstance(value, str) else str(value)
71
+
72
+ if key in ("baseURL", "screenshot", "video", "trace"):
73
+ use_m = re.search(r"(use\s*:\s*\{)", result, re.DOTALL)
74
+ if use_m:
75
+ pos = use_m.end()
76
+ result = result[:pos] + f"\n {key}: {val_str}," + result[pos:]
77
+ changes.append(f"Merged use.{key} = {val_str}")
78
+ continue
79
+
80
+ # Top-level setting
81
+ close = result.rfind("}")
82
+ if close >= 0:
83
+ result = result[:close] + f" {key}: {val_str},\n" + result[close:]
84
+ changes.append(f"Merged top-level {key} = {val_str}")
85
+
86
+ return result, changes
87
+
88
+
89
+ def _generate_pw_config(settings: dict) -> str:
90
+ base_url = settings.get("baseURL", "http://localhost:3000")
91
+ timeout = settings.get("timeout", 30_000)
92
+ retries = settings.get("retries", 1)
93
+ workers = settings.get("workers", 1)
94
+ ss = settings.get("screenshot", "only-on-failure")
95
+ video = settings.get("video", "retain-on-failure")
96
+ trace = settings.get("trace", "on-first-retry")
97
+
98
+ return (
99
+ "import { defineConfig } from '@playwright/test';\n\n"
100
+ "export default defineConfig({\n"
101
+ " testDir: './src/test',\n"
102
+ f" timeout: {timeout},\n"
103
+ f" retries: {retries},\n"
104
+ f" workers: {workers},\n"
105
+ " use: {\n"
106
+ f" baseURL: '{base_url}',\n"
107
+ f" screenshot: '{ss}',\n"
108
+ f" video: '{video}',\n"
109
+ f" trace: '{trace}',\n"
110
+ " },\n"
111
+ "});\n"
112
+ )
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Cucumber config
117
+ # ---------------------------------------------------------------------------
118
+
119
+ def merge_cucumber_config(source_content: str, helix_root: str) -> dict:
120
+ """
121
+ Extract profile definitions from a source cucumber.js/cjs/mjs and emit
122
+ a Helix cucumber.js at config/cucumber.js.
123
+
124
+ The file is intentionally written as CommonJS .js (NOT .ts) because:
125
+ • cucumber-js auto-loads its config file BEFORE ts-node is registered,
126
+ so a .ts config can't be parsed unless callers manually bootstrap ts-node.
127
+ • Keeping the path stable (config/cucumber.js) means existing npm scripts
128
+ that reference `--config=config/cucumber.js` continue to work after migration.
129
+
130
+ Returns { content: str, target: str, changes: list[str] }
131
+ """
132
+ profiles = _extract_cucumber_profiles(source_content)
133
+ target = "config/cucumber.js"
134
+
135
+ changes = []
136
+
137
+ if not profiles:
138
+ return {"content": "module.exports = {};\n", "target": target, "changes": []}
139
+
140
+ # Build fresh content — regenerate each profile as properly indented JavaScript
141
+ lines = ["module.exports = {"]
142
+ for name, body in profiles.items():
143
+ changes.append(f"Merged cucumber profile '{name}'")
144
+ dedented = textwrap.dedent(body).strip()
145
+ # Ensure dotenv loads BEFORE ts-node compiles any step / page-object
146
+ # module. Without this, `.env` is never read and runtime config knobs
147
+ # like HEADLESS, HEAL_STORE_PATH, ENABLE_AI_HEALING etc. silently
148
+ # fall back to their compile-time defaults.
149
+ dedented, injected = _ensure_dotenv_in_require_module(dedented)
150
+ if injected:
151
+ changes.append(f"Injected dotenv/config into '{name}' requireModule")
152
+ indented_body = "\n".join(
153
+ " " + ln if ln.strip() else ln
154
+ for ln in dedented.splitlines()
155
+ )
156
+ lines.append(f" {name}: {{")
157
+ lines.append(indented_body)
158
+ lines.append(" },")
159
+ lines.append("};\n")
160
+
161
+ return {"content": "\n".join(lines), "target": target, "changes": changes}
162
+
163
+
164
+ def _ensure_dotenv_in_require_module(body: str) -> tuple[str, bool]:
165
+ """
166
+ Prepend `"dotenv/config"` to the profile's `requireModule: [ ... ]` array
167
+ so dotenv loads before ts-node / tsconfig-paths bootstrap. If the profile
168
+ has no requireModule key, leave the body alone (signals a non-TS profile
169
+ that doesn't need dotenv pre-loading either).
170
+
171
+ Returns (new_body, injected_flag).
172
+ """
173
+ # Already present — nothing to do.
174
+ if re.search(r'requireModule\s*:\s*\[[^\]]*["\']dotenv/config["\']', body):
175
+ return body, False
176
+ pattern = re.compile(r'(requireModule\s*:\s*\[\s*)')
177
+ new, n = pattern.subn(r'\1"dotenv/config", ', body, count=1)
178
+ if n == 0:
179
+ return body, False
180
+ return new, True
181
+
182
+
183
+ def _extract_cucumber_profiles(cfg: str) -> dict[str, str]:
184
+ """
185
+ Extract top-level profile blocks using brace counting so nested objects
186
+ (e.g. formatOptions: { ... }) are captured correctly.
187
+ """
188
+ profiles: dict[str, str] = {}
189
+ # Skip JS builtins/keywords that appear as object/variable names, but NOT
190
+ # "default" — that is a valid cucumber profile name (module.exports.default).
191
+ _skip = {"module", "exports", "require", "const", "let", "var"}
192
+
193
+ # Find each <name>: { at the top-level of the outer object
194
+ key_re = re.compile(r"\b(\w+)\s*:\s*\{")
195
+ i = 0
196
+ while i < len(cfg):
197
+ m = key_re.search(cfg, i)
198
+ if not m:
199
+ break
200
+ name = m.group(1)
201
+ brace_start = m.end() - 1 # position of the opening '{'
202
+
203
+ # Count braces to find the matching closing '}'
204
+ depth = 0
205
+ j = brace_start
206
+ while j < len(cfg):
207
+ if cfg[j] == "{":
208
+ depth += 1
209
+ elif cfg[j] == "}":
210
+ depth -= 1
211
+ if depth == 0:
212
+ break
213
+ j += 1
214
+
215
+ # Keep leading newline so textwrap.dedent can detect common indent
216
+ body = cfg[brace_start + 1: j]
217
+
218
+ if name not in _skip:
219
+ profiles[name] = body
220
+
221
+ i = j + 1
222
+
223
+ return profiles
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # package.json — merge npm scripts
228
+ # ---------------------------------------------------------------------------
229
+
230
+ def merge_package_json(source_content: str, helix_root: str) -> dict:
231
+ """
232
+ Merge source package.json into the Helix one, preserving everything needed
233
+ to actually run the migrated test suite:
234
+
235
+ • scripts that invoke cucumber-js / playwright / cross-env
236
+ • dependencies — required at runtime by the migrated code
237
+ • devDependencies — required to build / type-check / install browsers
238
+ • engines / type / private fields if present
239
+
240
+ Existing Helix entries are NEVER overwritten (target wins on conflict).
241
+
242
+ Returns { content: str, target: str, changes: list[str] }
243
+ """
244
+ target = "package.json"
245
+ target_path = Path(helix_root) / target
246
+ changes: list[str] = []
247
+
248
+ try:
249
+ source_pkg = json.loads(source_content)
250
+ except Exception as exc:
251
+ return {"content": source_content, "target": target,
252
+ "changes": [f"Could not parse source package.json: {exc}"]}
253
+
254
+ if target_path.exists():
255
+ try:
256
+ target_pkg = json.loads(target_path.read_text(encoding="utf-8"))
257
+ except Exception:
258
+ target_pkg = _default_pkg()
259
+ else:
260
+ target_pkg = _default_pkg()
261
+
262
+ # ── scripts: bring over runner scripts only ─────────────────────────────
263
+ tgt_scripts = target_pkg.setdefault("scripts", {})
264
+ _RUNNERS = ("cucumber-js", "playwright", "cross-env")
265
+ for name, cmd in (source_pkg.get("scripts") or {}).items():
266
+ if name in tgt_scripts:
267
+ continue
268
+ if not any(r in cmd for r in _RUNNERS):
269
+ continue
270
+ tgt_scripts[name] = cmd
271
+ changes.append(f"Merged npm script '{name}'")
272
+
273
+ # ── healix dashboard launchers ──────────────────────────────────────────
274
+ # `healix:dashboard` starts the full read/write HealingDashboard at
275
+ # HEALING_DASHBOARD_PORT (default 7890).
276
+ # `healix:review` starts just the read-only review server at
277
+ # HEALIX_REVIEW_PORT (default 7891). Both honor whatever values are in
278
+ # .env — the only difference is `healix:review` disables the dashboard
279
+ # write port by default so QA leads can't accidentally approve/reject
280
+ # while running the review-only view.
281
+ _LAUNCHER = (
282
+ "ts-node -r tsconfig-paths/register src/utils/locators/dashboard-server.ts"
283
+ )
284
+ _HEALIX_SCRIPTS = {
285
+ "healix:dashboard": _LAUNCHER,
286
+ "healix:review": f"cross-env HEALING_DASHBOARD_PORT=0 HEALIX_REVIEW_PORT=${{HEALIX_REVIEW_PORT:-7891}} {_LAUNCHER}",
287
+ }
288
+ for name, cmd in _HEALIX_SCRIPTS.items():
289
+ if name in tgt_scripts:
290
+ continue
291
+ tgt_scripts[name] = cmd
292
+ changes.append(f"Added healix npm script: {name}")
293
+
294
+ # ── dependencies + devDependencies: bring over all unless already set ──
295
+ for key in ("dependencies", "devDependencies"):
296
+ src_block = source_pkg.get(key) or {}
297
+ if not src_block:
298
+ continue
299
+ tgt_block = target_pkg.setdefault(key, {})
300
+ for dep, version in src_block.items():
301
+ if dep in tgt_block:
302
+ continue
303
+ tgt_block[dep] = version
304
+ changes.append(f"Merged {key[:-1]}: {dep}@{version}")
305
+
306
+ # ── ensure the migrated framework's runtime deps are present ───────────
307
+ # winston is required by every healer-injected page object / step def.
308
+ deps = target_pkg.setdefault("dependencies", {})
309
+ if "winston" not in deps and "winston" not in target_pkg.get("devDependencies", {}):
310
+ deps["winston"] = "^3.13.0"
311
+ changes.append("Added runtime dep: winston (healer logging)")
312
+ # dotenv is required so the .env at the migrated tree root is loaded
313
+ # BEFORE any module reads process.env. The generated cucumber.js wires
314
+ # it into `requireModule` (see merge_cucumber_config), but the package
315
+ # itself must be installable too.
316
+ if "dotenv" not in deps and "dotenv" not in target_pkg.get("devDependencies", {}):
317
+ deps["dotenv"] = "^16.4.5"
318
+ changes.append("Added runtime dep: dotenv (.env loader for cucumber)")
319
+
320
+ # ── TypeScript toolchain — migrated tree is always .ts ─────────────────
321
+ # We always emit TypeScript step defs / page objects / locators, so the
322
+ # migrated project must be able to build and run them. cucumber-js needs
323
+ # ts-node to load .ts files; tsconfig-paths resolves @-aliases at runtime.
324
+ dev_deps = target_pkg.setdefault("devDependencies", {})
325
+ _REQUIRED_DEV: dict[str, str] = {
326
+ "typescript": "^5.4.5",
327
+ "ts-node": "^10.9.2",
328
+ "@cucumber/cucumber": "^10.8.0",
329
+ "@types/node": "^20.14.2",
330
+ "tsconfig-paths": "^4.2.0",
331
+ }
332
+ for dep, version in _REQUIRED_DEV.items():
333
+ if dep not in dev_deps and dep not in deps:
334
+ dev_deps[dep] = version
335
+ changes.append(f"Added devDependency: {dep}@{version} (TS/BDD toolchain)")
336
+
337
+ # ── @types/* companions for JS-only packages the source depends on ─────
338
+ # Curated list of packages that don't ship TypeScript declarations and
339
+ # have an `@types/<name>` counterpart on DefinitelyTyped. When the source
340
+ # uses one of these, the migrated tree needs the types for tsc to resolve
341
+ # the imports under TypeScript. We only add the @types/* when:
342
+ # 1. The package itself appears in deps or devDeps (source or merged), AND
343
+ # 2. The @types counterpart isn't already declared (anywhere).
344
+ #
345
+ # Versions are intentionally generic (`*`) — types packages are loosely
346
+ # versioned and don't need to match the runtime version exactly.
347
+ _TYPES_FOR: dict[str, str] = {
348
+ "fs-extra": "^11.0.0",
349
+ "lodash": "^4.17.0",
350
+ "glob": "^8.1.0",
351
+ "mkdirp": "^2.0.0",
352
+ "rimraf": "^4.0.0",
353
+ "uuid": "^9.0.0",
354
+ "js-yaml": "^4.0.0",
355
+ "jsonwebtoken": "^9.0.0",
356
+ "module-alias": "^2.0.0",
357
+ "cookie": "^0.6.0",
358
+ "express": "^4.17.0",
359
+ "cors": "^2.8.0",
360
+ "node-fetch": "^2.6.0",
361
+ "minimist": "^1.2.0",
362
+ "yargs": "^17.0.0",
363
+ "semver": "^7.5.0",
364
+ }
365
+ all_pkg_names = set(deps.keys()) | set(dev_deps.keys())
366
+ src_dep_names: set[str] = set()
367
+ for key in ("dependencies", "devDependencies"):
368
+ src_dep_names.update((source_pkg.get(key) or {}).keys())
369
+ needs_types = all_pkg_names | src_dep_names
370
+ for js_pkg, types_version in _TYPES_FOR.items():
371
+ if js_pkg not in needs_types:
372
+ continue
373
+ types_pkg = f"@types/{js_pkg}"
374
+ if types_pkg in dev_deps or types_pkg in deps:
375
+ continue
376
+ dev_deps[types_pkg] = types_version
377
+ changes.append(
378
+ f"Added devDependency: {types_pkg}@{types_version} "
379
+ f"(TS types for {js_pkg})"
380
+ )
381
+
382
+ # ── single-field passthroughs the user almost always wants ─────────────
383
+ for field in ("type", "private", "engines"):
384
+ if field in source_pkg and field not in target_pkg:
385
+ target_pkg[field] = source_pkg[field]
386
+ changes.append(f"Carried over field: {field}")
387
+
388
+ content = json.dumps(target_pkg, indent=2) + "\n"
389
+ return {"content": content, "target": target, "changes": changes}
390
+
391
+
392
+ def _default_pkg() -> dict:
393
+ return {
394
+ "name": "helix-qa",
395
+ "version": "1.0.0",
396
+ "scripts": {},
397
+ }
398
+
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # tsconfig.json — preserve source compiler options, ensure Helix path aliases
402
+ # ---------------------------------------------------------------------------
403
+
404
+ _HELIX_PATHS = {
405
+ "@pages/*": ["pages/*"],
406
+ "@steps/*": ["test/steps/*"],
407
+ "@features/*": ["test/features/*"],
408
+ "@locators/*": ["locators/*"],
409
+ "@config/*": ["config/*"],
410
+ "@utils/*": ["utils/*"],
411
+ "@helper/*": ["helper/*"],
412
+ "@hooks/*": ["hooks/*"],
413
+ }
414
+
415
+
416
+ def merge_tsconfig_json(source_content: str, helix_root: str) -> dict:
417
+ """
418
+ Take the source tsconfig.json as a baseline and ensure the Helix path
419
+ aliases (@pages, @steps, @helper, @hooks, ...) are all present. Source
420
+ options are preserved verbatim — we only add what's truly missing for
421
+ the migrated tree to load (path aliases, baseUrl).
422
+
423
+ Notably: we do NOT add `strict: true` or `strictNullChecks: false`. The
424
+ strictness setting is a project-wide policy decision that belongs to the
425
+ source author; introducing strictness the source didn't have would
426
+ surface cascading errors on code that compiled fine before migration.
427
+ If the source omitted `strict`, the migrated project also omits it
428
+ (TypeScript's default is non-strict).
429
+
430
+ Returns { content: str, target: str, changes: list[str] }
431
+ """
432
+ target = "tsconfig.json"
433
+ changes: list[str] = []
434
+
435
+ try:
436
+ # Strip // and /* */ comments — tsconfig allows them, json doesn't
437
+ cleaned = _strip_jsonc(source_content)
438
+ cfg = json.loads(cleaned)
439
+ except Exception as exc:
440
+ # If parsing failed, fall back to a minimal Helix tsconfig.
441
+ cfg = {}
442
+ changes.append(f"Could not parse source tsconfig.json ({exc}); using defaults")
443
+
444
+ opts = cfg.setdefault("compilerOptions", {})
445
+
446
+ # baseUrl + paths — needed so @-aliased imports resolve under src/.
447
+ opts.setdefault("baseUrl", "src")
448
+ paths = opts.setdefault("paths", {})
449
+ for alias, mapping in _HELIX_PATHS.items():
450
+ if alias not in paths:
451
+ paths[alias] = mapping
452
+ changes.append(f"Added tsconfig alias: {alias}")
453
+
454
+ # Toolchain defaults — only when source omits them, so the migrated
455
+ # project can at least parse and emit. These are non-policy options
456
+ # (output format, lib targeting) so adding them is safe.
457
+ opts.setdefault("skipLibCheck", True)
458
+ opts.setdefault("esModuleInterop", True)
459
+ opts.setdefault("resolveJsonModule", True)
460
+ opts.setdefault("target", "ES2021")
461
+ opts.setdefault("module", "commonjs")
462
+ opts.setdefault("lib", ["dom", "es2021"])
463
+
464
+ if "include" not in cfg:
465
+ cfg["include"] = ["src"]
466
+ changes.append("Set include: ['src']")
467
+
468
+ content = json.dumps(cfg, indent=2) + "\n"
469
+ return {"content": content, "target": target, "changes": changes}
470
+
471
+
472
+ def _strip_jsonc(text: str) -> str:
473
+ """Strip line- and block-comments so json.loads can parse tsconfig."""
474
+ out: list[str] = []
475
+ i = 0
476
+ n = len(text)
477
+ in_str = False
478
+ str_ch = ""
479
+ while i < n:
480
+ c = text[i]
481
+ if in_str:
482
+ out.append(c)
483
+ if c == "\\" and i + 1 < n:
484
+ out.append(text[i + 1])
485
+ i += 2
486
+ continue
487
+ if c == str_ch:
488
+ in_str = False
489
+ i += 1
490
+ continue
491
+ if c in ('"', "'"):
492
+ in_str = True
493
+ str_ch = c
494
+ out.append(c)
495
+ i += 1
496
+ continue
497
+ if c == "/" and i + 1 < n:
498
+ if text[i + 1] == "/":
499
+ # line comment — skip to end of line
500
+ j = text.find("\n", i)
501
+ if j < 0:
502
+ break
503
+ i = j
504
+ continue
505
+ if text[i + 1] == "*":
506
+ j = text.find("*/", i + 2)
507
+ if j < 0:
508
+ break
509
+ i = j + 2
510
+ continue
511
+ out.append(c)
512
+ i += 1
513
+ return "".join(out)