@qa-gentic/stlc-agents 1.0.27 → 1.0.29

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 (47) 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/bin/postinstall.js +14 -4
  7. package/package.json +19 -7
  8. package/skills/migrate-framework/SKILL.md +207 -0
  9. package/src/stlc_agents/agent_migration/__init__.py +0 -0
  10. package/src/stlc_agents/agent_migration/_migrate.py +1398 -0
  11. package/src/stlc_agents/agent_migration/cli.py +217 -0
  12. package/src/stlc_agents/agent_migration/detector.py +81 -0
  13. package/src/stlc_agents/agent_migration/mapper.py +439 -0
  14. package/src/stlc_agents/agent_migration/reporter.py +86 -0
  15. package/src/stlc_agents/agent_migration/server.py +267 -0
  16. package/src/stlc_agents/agent_migration/transformer/__init__.py +0 -0
  17. package/src/stlc_agents/agent_migration/transformer/config_merger.py +513 -0
  18. package/src/stlc_agents/agent_migration/transformer/healer_injector.py +1143 -0
  19. package/src/stlc_agents/agent_migration/transformer/import_fixer.py +419 -0
  20. package/src/stlc_agents/agent_migration/transformer/js_to_ts.py +413 -0
  21. package/src/stlc_agents/agent_migration/transformer/local_var_hoister.py +378 -0
  22. package/src/stlc_agents/agent_migration/transformer/locator_moderniser.py +132 -0
  23. package/src/stlc_agents/agent_migration/transformer/locator_registrar.py +328 -0
  24. package/src/stlc_agents/agent_migration/transformer/spec_to_bdd.py +820 -0
  25. package/src/stlc_agents/agent_playwright_generator/server.py +926 -91
  26. package/src/stlc_agents/__pycache__/__init__.cpython-314.pyc +0 -0
  27. package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-314.pyc +0 -0
  29. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  30. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-314.pyc +0 -0
  31. package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-314.pyc +0 -0
  32. package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-314.pyc +0 -0
  33. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  34. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-314.pyc +0 -0
  35. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-314.pyc +0 -0
  36. package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-314.pyc +0 -0
  37. package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-314.pyc +0 -0
  38. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  39. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-314.pyc +0 -0
  40. package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-314.pyc +0 -0
  41. package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-314.pyc +0 -0
  42. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  43. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-314.pyc +0 -0
  44. package/src/stlc_agents/shared/__pycache__/__init__.cpython-314.pyc +0 -0
  45. package/src/stlc_agents/shared/__pycache__/auth.cpython-314.pyc +0 -0
  46. package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-314.pyc +0 -0
  47. package/src/stlc_agents/shared/__pycache__/pricing.cpython-314.pyc +0 -0
@@ -0,0 +1,1398 @@
1
+ """
2
+ Core migration pipeline — shared by both the MCP server and the CLI.
3
+
4
+ Pipeline per file:
5
+ 1. JS → TS conversion (only when source is .js)
6
+ 2. Locator modernisation (locators, page objects, step defs)
7
+ 3. Healer injection (page objects and step defs)
8
+ 4. Import path fixing (all .ts files)
9
+
10
+ Config files are handled separately via merge helpers.
11
+ """
12
+ from __future__ import annotations
13
+ import json
14
+ import shutil
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from .transformer.js_to_ts import convert_js_to_ts
19
+ from .transformer.locator_moderniser import modernise_locators
20
+ from .transformer.locator_registrar import transform_locator_file, collect_object_keys, fix_selector_accesses
21
+ from .transformer.local_var_hoister import (
22
+ HoistAction,
23
+ hoist_inline_locators,
24
+ append_entries_to_locator_file,
25
+ )
26
+ from .transformer.healer_injector import inject_healers
27
+ from .transformer.import_fixer import fix_imports
28
+ from .transformer.spec_to_bdd import convert_spec_to_bdd
29
+ from .transformer.js_to_ts import _fix_tobe_typo, _cast_intl_format_options # type: ignore
30
+ from .transformer.config_merger import (
31
+ merge_playwright_config,
32
+ merge_cucumber_config,
33
+ merge_package_json,
34
+ merge_tsconfig_json,
35
+ )
36
+ from .reporter import generate_report
37
+
38
+ _TRANSFORM_ROLES = {"locator", "page_object", "step_def"}
39
+
40
+ # Roles that are TypeScript/JavaScript code but don't need locator/healer transformation —
41
+ # they just need JS→TS conversion (if .js) and import-path rewriting.
42
+ _CODE_COPY_ROLES = {"helper", "hook", "fixture", "support", "api_client"}
43
+
44
+ # Roles that are copied byte-for-byte (no source transformation).
45
+ # "unknown" is the catch-all: any file the mapper didn't classify (README.md,
46
+ # LICENSE, .gitignore, vrt-baselines, arbitrary docs) lands here and is
47
+ # preserved at its original relative path so the migration never silently
48
+ # drops content from the source project.
49
+ _RAW_COPY_ROLES = {"docker", "env", "ci", "script", "unknown"}
50
+
51
+
52
+ def run_migration(
53
+ source_dir: str,
54
+ helix_root: str,
55
+ file_mappings: list[dict],
56
+ conflict_strategy: str = "interactive",
57
+ framework: str = "playwright-bdd-ts",
58
+ integration: str = "both",
59
+ ) -> dict:
60
+ """
61
+ Execute the full migration for a list of file mappings.
62
+
63
+ conflict_strategy:
64
+ "overwrite" — always replace existing Helix files
65
+ "skip" — never touch existing Helix files
66
+ "interactive" — report conflicts in result; caller decides per file
67
+
68
+ integration:
69
+ "ado" — install ADO skills (qa-test-case-manager + shared)
70
+ "jira" — install Jira skills (qa-jira-manager + shared)
71
+ "both" — install everything (default — every agent reachable)
72
+
73
+ Returns {
74
+ written: list[dict],
75
+ skipped: list[dict],
76
+ conflicts: list[dict],
77
+ todo_count: int,
78
+ todos: list[str],
79
+ report_path: str | None,
80
+ }
81
+ """
82
+ written: list[dict] = []
83
+ skipped: list[dict] = []
84
+ conflicts: list[dict] = []
85
+ todos: list[str] = []
86
+ root = Path(helix_root)
87
+
88
+ # Authoritative lookup so import_fixer can rewrite imports against the
89
+ # mapper's real output paths instead of re-predicting them (which would
90
+ # drift apart after disambiguation, kebab tweaks, etc.). Keyed by the
91
+ # resolved absolute source path; value is the helix-relative target.
92
+ source_to_target: dict[str, str] = {
93
+ str(Path(m["source"]).resolve()): m["target"]
94
+ for m in file_mappings
95
+ if m.get("target")
96
+ }
97
+
98
+ # Accumulator for HoistActions emitted while transforming page_object /
99
+ # step_def files. Applied to locator files in a post-pass so all hoisted
100
+ # entries land in their target xxxLocators block.
101
+ hoist_buffer: dict[str, list[HoistAction]] = {}
102
+
103
+ # Pre-pass: scan every locator-source file so we know which keys are
104
+ # object-valued ({selector, intent, stability}) versus string-valued
105
+ # (display labels, URLs). The healer must not wrap references whose key
106
+ # is string-valued — `.selector` would not exist on the plain string.
107
+ object_keys_by_name: dict[str, set[str]] = {}
108
+ for mp in file_mappings:
109
+ if mp.get("role") != "locator":
110
+ continue
111
+ try:
112
+ src_content = Path(mp["source"]).read_text(encoding="utf-8")
113
+ # modernise_locators returns (content, changes, todo_count) —
114
+ # only the first matters here. transform_locator_file returns
115
+ # (content, changes). Use `*_` to absorb any future trailing
116
+ # values so a signature change can't silently re-break this.
117
+ modernised, *_ = modernise_locators(src_content)
118
+ transformed, *_ = transform_locator_file(modernised)
119
+ for name, keys in collect_object_keys(transformed).items():
120
+ object_keys_by_name.setdefault(name, set()).update(keys)
121
+ except Exception as exc:
122
+ # Surface the failure: a silent `pass` here was hiding a tuple
123
+ # unpacking bug for who knows how long, which left every
124
+ # consumer's `object_keys` map empty (so the healer wrapped
125
+ # everything indiscriminately, including string-valued labels).
126
+ import sys
127
+ print(f"warning: object_keys pre-pass failed for {mp['relative']}: {exc}", file=sys.stderr)
128
+
129
+ for mapping in file_mappings:
130
+ role = mapping["role"]
131
+
132
+ if role.startswith("config_"):
133
+ _process_config(mapping, source_dir, helix_root, written, skipped)
134
+ continue
135
+
136
+ source_path = Path(mapping["source"])
137
+ target_rel = mapping["target"]
138
+ if not target_rel:
139
+ skipped.append({"source": mapping["relative"], "reason": f"no target for role={role}"})
140
+ continue
141
+
142
+ # Per-app `.env.<APP>` files are NOT raw-copied — their values are
143
+ # folded into the single `.env.example` at helix-qa root by the
144
+ # _write_root_env_example post-pass below. Skipping prevents stale
145
+ # per-app files from sticking around alongside the consolidated file.
146
+ if role == "env" and source_path.name.startswith(".env.") and source_path.name != ".env.example":
147
+ skipped.append({
148
+ "source": mapping["relative"],
149
+ "reason": "per-app .env folded into single .env.example",
150
+ })
151
+ continue
152
+
153
+ # ── Playwright spec → feature + steps split ─────────────────────────
154
+ # Non-BDD Playwright spec files are converted into a Gherkin .feature
155
+ # file plus a Cucumber .steps.ts file. The .steps.ts goes to the
156
+ # target_rel computed by the mapper; the .feature is derived from it.
157
+ if role == "playwright_spec":
158
+ try:
159
+ content = source_path.read_text(encoding="utf-8")
160
+ feature_content, steps_content, changes = convert_spec_to_bdd(
161
+ content, Path(target_rel).stem.replace(".steps", ""),
162
+ )
163
+ if not steps_content:
164
+ skipped.append({
165
+ "source": mapping["relative"],
166
+ "reason": "no test() or test.describe() blocks found",
167
+ })
168
+ continue
169
+ # Run import_fixer on the generated step file so imports of
170
+ # page_objects/helpers (preserved from the source's require())
171
+ # get rewritten to the new Helix locations.
172
+ steps_content, imp_changes = fix_imports(
173
+ steps_content, mapping["source"], target_rel, source_dir,
174
+ source_to_target=source_to_target,
175
+ )
176
+ # Apply the targeted JS→TS fixes that also matter inside step
177
+ # bodies (typo normalisation, Intl.* option casts). The other
178
+ # js_to_ts passes are skipped because they're aimed at class
179
+ # bodies, not free-standing step callbacks.
180
+ steps_content, tobe_changes = _fix_tobe_typo(steps_content)
181
+ steps_content, intl_changes2 = _cast_intl_format_options(steps_content)
182
+ changes.extend(tobe_changes)
183
+ changes.extend(intl_changes2)
184
+ # Steps file
185
+ steps_path = root / target_rel
186
+ steps_path.parent.mkdir(parents=True, exist_ok=True)
187
+ steps_path.write_text(steps_content, encoding="utf-8")
188
+ # Feature file — derive from steps target.
189
+ feature_rel = target_rel.replace("src/test/steps/", "src/test/features/", 1)
190
+ feature_rel = feature_rel.replace(".steps.ts", ".feature")
191
+ feature_path = root / feature_rel
192
+ feature_path.parent.mkdir(parents=True, exist_ok=True)
193
+ feature_path.write_text(feature_content, encoding="utf-8")
194
+ written.append({
195
+ "source": mapping["relative"],
196
+ "target": target_rel,
197
+ "role": "step_def",
198
+ "change_count": len(changes) + len(imp_changes),
199
+ })
200
+ written.append({
201
+ "source": mapping["relative"],
202
+ "target": feature_rel,
203
+ "role": "feature",
204
+ "change_count": len(changes),
205
+ })
206
+ except Exception as exc:
207
+ skipped.append({
208
+ "source": mapping["relative"],
209
+ "reason": f"spec→BDD conversion error: {exc}",
210
+ })
211
+ continue
212
+
213
+ # ── Raw-copy roles: Dockerfile, .env, CI yaml, shell scripts ────────
214
+ # Also raw-copy JSON data files under helper/ (test data, fixtures).
215
+ is_raw_copy = role in _RAW_COPY_ROLES or (
216
+ role in _CODE_COPY_ROLES and source_path.suffix == ".json"
217
+ )
218
+ if is_raw_copy:
219
+ try:
220
+ _copy_raw(source_path, root / target_rel)
221
+ written.append({
222
+ "source": mapping["relative"],
223
+ "target": target_rel,
224
+ "role": role,
225
+ "change_count": 0,
226
+ })
227
+ except Exception as exc:
228
+ skipped.append({"source": mapping["relative"], "reason": f"raw-copy error: {exc}"})
229
+ continue
230
+
231
+ try:
232
+ content = source_path.read_text(encoding="utf-8")
233
+ except Exception as exc:
234
+ skipped.append({"source": mapping["relative"], "reason": f"read error: {exc}"})
235
+ continue
236
+
237
+ # ── Transformation pipeline ─────────────────────────────────────
238
+ change_count = 0
239
+ file_todos: list[str] = []
240
+
241
+ # 1. JS → TS
242
+ if mapping.get("needs_js_to_ts"):
243
+ content, js_changes = convert_js_to_ts(content)
244
+ change_count += len(js_changes)
245
+
246
+ # 2. Locator modernisation
247
+ if role in _TRANSFORM_ROLES:
248
+ content, loc_changes, loc_todos = modernise_locators(content)
249
+ change_count += len(loc_changes)
250
+ for _ in range(loc_todos):
251
+ file_todos.append(
252
+ f"`{target_rel}` — locator needs manual review (complex CSS/XPath kept as-is)"
253
+ )
254
+
255
+ # 2b. Locator registration — convert plain string values to { selector, intent, stability }
256
+ if role == "locator":
257
+ content, reg_changes = transform_locator_file(content)
258
+ change_count += len(reg_changes)
259
+
260
+ # 2c. Hoist inline-string locators from local variables into the
261
+ # centralised xxxLocators. Buffered; applied to locator files in a
262
+ # post-pass. Must run BEFORE healer injection so the rewritten RHS
263
+ # (xxxLocators.foo.selector) is visible to healer_injector's var_map.
264
+ if role in ("page_object", "step_def"):
265
+ content, hoist_actions, hoist_changes = hoist_inline_locators(content)
266
+ change_count += len(hoist_changes)
267
+ for action in hoist_actions:
268
+ hoist_buffer.setdefault(action.locator_object, []).append(action)
269
+ # Hoisted entries are by construction object-valued, so register
270
+ # them in object_keys_by_name immediately. Without this, the
271
+ # healer call below would not recognise them as wrappable.
272
+ object_keys_by_name.setdefault(action.locator_object, set()).add(action.entry_name)
273
+
274
+ # 3. Healer injection
275
+ if role in ("page_object", "step_def"):
276
+ content, heal_changes, heal_todos = inject_healers(
277
+ content, role, object_keys=object_keys_by_name,
278
+ )
279
+ change_count += len(heal_changes)
280
+ if heal_todos:
281
+ file_todos.append(
282
+ f"`{target_rel}` — healer init needed (see inline TODO comment)"
283
+ )
284
+
285
+ # 4. Import path fixing (applies to all code roles, including helper/hook/fixture/support)
286
+ content, imp_changes = fix_imports(
287
+ content, mapping["source"], target_rel, source_dir,
288
+ source_to_target=source_to_target,
289
+ )
290
+ change_count += len(imp_changes)
291
+
292
+ todos.extend(file_todos)
293
+
294
+ # ── Conflict check ──────────────────────────────────────────────
295
+ target_path = root / target_rel
296
+
297
+ if target_path.exists():
298
+ if conflict_strategy == "skip":
299
+ skipped.append({
300
+ "source": mapping["relative"],
301
+ "reason": "target already exists (skipped per strategy)",
302
+ })
303
+ continue
304
+ elif conflict_strategy == "overwrite":
305
+ conflicts.append({"path": target_rel, "resolution": "overwritten"})
306
+ else: # interactive
307
+ conflicts.append({
308
+ "path": target_rel,
309
+ "source": mapping["relative"],
310
+ "resolution": "pending — awaiting user decision",
311
+ })
312
+ # In interactive mode we still write; MCP callers can re-invoke
313
+ # with conflict_strategy="skip" for specific files if desired.
314
+
315
+ # ── Write ──────────────────────────────────────────────────────
316
+ target_path.parent.mkdir(parents=True, exist_ok=True)
317
+ target_path.write_text(content, encoding="utf-8")
318
+
319
+ written.append({
320
+ "source": mapping["relative"],
321
+ "target": target_rel,
322
+ "role": role,
323
+ "change_count": change_count,
324
+ })
325
+
326
+ # ── Post-pass: ensure tsconfig.json exists ──────────────────────────────
327
+ # JS source projects (e.g. playwright-js) don't ship a tsconfig.json, but
328
+ # the migrated tree is always TypeScript — synthesise one so the user can
329
+ # `tsc` / `cucumber-js` against the migrated code without manual setup.
330
+ _ensure_tsconfig(root)
331
+
332
+ # ── Post-pass: flush buffered hoist actions into the centralised locator files ──
333
+ # During the per-file loop we rewrote local-var RHS to reference xxxLocators
334
+ # entries that don't exist yet. This post-pass appends those entries to the
335
+ # right xxxLocators block in the right .locators.ts file on disk.
336
+ _flush_hoist_buffer(root, hoist_buffer)
337
+
338
+ # ── Post-pass: fix locator .selector accesses in page objects / step defs ──
339
+ # Locator files are already written with { selector, intent, stability } format.
340
+ # Now read them all to collect which keys are objects, then update page objects.
341
+ _fix_selector_accesses_in_tree(root, written)
342
+
343
+ # ── Post-pass: emit single `.env.example` at helix-qa root ───────────────
344
+ # Folds any source per-app `.env.<APP>` files into KEY_<APP>=… entries so
345
+ # the framework runs from one consolidated env file.
346
+ _write_root_env_example(root, written, source_dir, conflict_strategy)
347
+
348
+ # ── Post-pass: install the single-source env loader ──────────────────────
349
+ # Replaces the migrated `environment.util.ts` with one that reads root .env
350
+ # and resolves APP-suffixed vars (DOMAIN_<APP> → DOMAIN) at runtime.
351
+ _patch_environment_util(root)
352
+
353
+ # ── Post-pass: scaffold the self-healing locator infrastructure ─────────
354
+ # All migrated page objects / step defs import from @utils/locators/* — they
355
+ # need LocatorHealer, LocatorRepository, TimingHealer, VisualIntentChecker
356
+ # and HealingDashboard to be present in the target tree. The scaffold
357
+ # honors the same conflict strategy as the main migration loop so users
358
+ # who pass --conflict overwrite get a fresh scaffold that picks up
359
+ # tool-side enhancements (new env-var wiring, new strategies, etc.).
360
+ _scaffold_locators(root, written, conflict_strategy)
361
+
362
+ # ── Scaffold pageFixture.ts when missing ────────────────────────────────
363
+ # Spec→BDD-generated step files import `fixture` from `@hooks/pageFixture`.
364
+ # When the source didn't carry its own hook (typical for non-BDD Playwright
365
+ # projects), drop in a minimal fixture so the generated steps resolve.
366
+ _scaffold_page_fixture(root)
367
+
368
+ # ── Scaffold globals.d.ts for browser-context identifiers ───────────────
369
+ # page.evaluate() callbacks run in the browser and may reference functions
370
+ # defined by the SUT (e.g. `toggleGridFiltersMenu()`). TypeScript can't
371
+ # see those — emit `declare const` shims so tsc doesn't flag them.
372
+ _scaffold_browser_globals(root)
373
+
374
+ # ── Fill in the full Helix-QA boilerplate ──────────────────────────────
375
+ # The qa-helix-writer / qa-playwright-generator agents expect a fully
376
+ # populated Helix-QA tree (base page, base locators, AI assistant
377
+ # helpers, retry/wait/logger utils, auth manager, templates, ...). The
378
+ # tool's own scaffold only emits the 6 core locator-infra files; the
379
+ # remaining ~25 files come from agent_helix_writer.boilerplate.BOILERPLATE,
380
+ # which is the authoritative source the agents themselves use when they
381
+ # bootstrap a fresh project. Fill in any file that the migration hasn't
382
+ # already produced (and never clobber the enhanced scaffold variants).
383
+ _fill_helix_boilerplate(root, written, conflict_strategy)
384
+
385
+ # ── Install agent skills + MCP config + governance docs ─────────────────
386
+ # The migration's whole point is to produce a tree the QA STLC agents
387
+ # (qa-test-case-manager, qa-gherkin-generator, qa-playwright-generator,
388
+ # qa-helix-writer, qa-jira-manager) can operate against. Drop in:
389
+ # • .claude/skills/<name>/SKILL.md — instructions consumed by Claude Code
390
+ # • .mcp.json — MCP server registry (auto-discovered)
391
+ # • AGENT-BEHAVIOR.md / ORCHESTRATION_RULES.md — governance documents
392
+ # Honors the same conflict strategy so re-migrations refresh stale files.
393
+ _install_agent_assets(root, written, integration, conflict_strategy)
394
+
395
+ # ── Report ─────────────────────────────────────────────────────────────
396
+ report_path: str | None = None
397
+ try:
398
+ report_path = generate_report(
399
+ helix_root=helix_root,
400
+ source_dir=source_dir,
401
+ framework=framework,
402
+ written=written,
403
+ skipped=skipped,
404
+ conflicts=conflicts,
405
+ todos=todos,
406
+ )
407
+ except Exception:
408
+ pass
409
+
410
+ return {
411
+ "written": written,
412
+ "skipped": skipped,
413
+ "conflicts": conflicts,
414
+ "todo_count": len(todos),
415
+ "todos": todos,
416
+ "report_path": report_path,
417
+ }
418
+
419
+
420
+ # ---------------------------------------------------------------------------
421
+ # Config helpers
422
+ # ---------------------------------------------------------------------------
423
+
424
+ def _process_config(
425
+ mapping: dict,
426
+ source_dir: str,
427
+ helix_root: str,
428
+ written: list[dict],
429
+ skipped: list[dict],
430
+ ) -> None:
431
+ role = mapping["role"]
432
+ source_path = Path(mapping["source"])
433
+
434
+ try:
435
+ content = source_path.read_text(encoding="utf-8")
436
+ except Exception as exc:
437
+ skipped.append({"source": mapping["relative"], "reason": f"read error: {exc}"})
438
+ return
439
+
440
+ try:
441
+ if role == "config_playwright":
442
+ # The migrated tree is always cucumber-js BDD (runner = cucumber-js,
443
+ # not @playwright/test). The Playwright Test runner config is dead
444
+ # weight and confuses tsc, so skip emitting it.
445
+ skipped.append({
446
+ "source": mapping["relative"],
447
+ "reason": "playwright.config not needed in cucumber-js BDD output",
448
+ })
449
+ return
450
+ elif role == "config_cucumber":
451
+ result = merge_cucumber_config(content, helix_root)
452
+ elif role == "config_package":
453
+ result = merge_package_json(content, helix_root)
454
+ elif role == "config_tsconfig":
455
+ result = merge_tsconfig_json(content, helix_root)
456
+ else:
457
+ skipped.append({"source": mapping["relative"], "reason": f"config type '{role}' not handled"})
458
+ return
459
+
460
+ target = Path(helix_root) / result["target"]
461
+ target.parent.mkdir(parents=True, exist_ok=True)
462
+ target.write_text(result["content"], encoding="utf-8")
463
+
464
+ written.append({
465
+ "source": mapping["relative"],
466
+ "target": result["target"],
467
+ "role": role,
468
+ "change_count": len(result.get("changes", [])),
469
+ })
470
+ except Exception as exc:
471
+ skipped.append({"source": mapping["relative"], "reason": f"config merge error: {exc}"})
472
+
473
+
474
+ # ---------------------------------------------------------------------------
475
+ # Raw-copy helper (Docker, .env, CI yaml, scripts)
476
+ # ---------------------------------------------------------------------------
477
+
478
+ def _copy_raw(source: Path, target: Path) -> None:
479
+ """Byte-for-byte copy preserving file mode (e.g. executable bit for .sh)."""
480
+ import shutil
481
+ target.parent.mkdir(parents=True, exist_ok=True)
482
+ shutil.copy2(source, target)
483
+
484
+
485
+ # ---------------------------------------------------------------------------
486
+ # Project-root .env.example template
487
+ # ---------------------------------------------------------------------------
488
+
489
+ _ENV_EXAMPLE_HEADER = """\
490
+ # ──────────────────────────────────────────────────────────────────────────────
491
+ # Helix-QA — runtime configuration (single source of truth)
492
+ #
493
+ # Copy this file to `.env` and fill in the values you need.
494
+ # Variables prefixed with the active APP (e.g. DOMAIN_AMPLIFY) are resolved to
495
+ # their canonical names (DOMAIN) automatically by environment.util.ts based on
496
+ # the APP env var supplied by the npm script.
497
+ # Lines starting with # are comments — do NOT put inline comments after values.
498
+ # ──────────────────────────────────────────────────────────────────────────────
499
+ """
500
+
501
+ _ENV_EXAMPLE_TAIL = """\
502
+
503
+ # ── Azure Key Vault (test data + secret resolution) ──────────────────────────
504
+ # Required by `currentFixture.azureKeyVault.getSecrets(...)` calls in helpers.
505
+ # In CI, these are typically set by the npm scripts via cross-env. Locally:
506
+ # AZURE_TENANT_ID=
507
+ # AZURE_CLIENT_ID=
508
+ # AZURE_CLIENT_SECRET=
509
+
510
+
511
+ # ── Self-healing AI vision (strategy 6 of LocatorHealer) ─────────────────────
512
+ #
513
+ # ENABLE_AI_HEALING
514
+ # true (default) — strategy 6 is active; calls the provider below on failure
515
+ # false — strategy 6 is skipped entirely; strategies 7 & 8
516
+ # (CDPSession AX tree + bounding box) still run
517
+ #
518
+ # AI_HEALING_PROVIDER
519
+ # anthropic (default) — direct Anthropic API; requires AI_HEALING_API_KEY
520
+ # copilot — GitHub Copilot chat completions; requires GITHUB_TOKEN
521
+ # claude-code — Claude Code local proxy; requires ANTHROPIC_API_KEY
522
+ ENABLE_AI_HEALING=true
523
+ AI_HEALING_PROVIDER=anthropic
524
+
525
+ # Required when AI_HEALING_PROVIDER=anthropic
526
+ # AI_HEALING_API_KEY=sk-ant-...
527
+
528
+ # Required when AI_HEALING_PROVIDER=copilot
529
+ # GITHUB_TOKEN=ghp_...
530
+
531
+ # Required when AI_HEALING_PROVIDER=claude-code
532
+ # (also set automatically inside a `claude` CLI session — no manual key needed)
533
+ # ANTHROPIC_API_KEY=sk-ant-...
534
+
535
+
536
+ # ── Healing Dashboard ────────────────────────────────────────────────────────
537
+ # Set HEALING_DASHBOARD_PORT=0 to disable the HTTP dashboard in CI pipelines.
538
+ # HEALIX_REVIEW_PORT, when set, starts a second READ-ONLY HTTP server that
539
+ # mirrors the dashboard's GET endpoints but rejects POST. Use it for QA leads
540
+ # / managers / external dashboards that need to inspect heals without write
541
+ # access. Off by default (0 = disabled).
542
+ # HEALING_DASHBOARD_PORT=7890
543
+ # HEALIX_REVIEW_PORT=7891
544
+
545
+
546
+ # ── LocatorRepository (heal store) ───────────────────────────────────────────
547
+ # HEAL_STORE_PATH Path to the heal-store JSON. The LocatorRepository
548
+ # reads it on startup and writes (debounced) on every
549
+ # successful heal. Default: ./self-heals/healed-locators.json
550
+ # HEAL_LOG_PATH One-line-per-heal audit log (tab-separated:
551
+ # timestamp / key / strategy / selector). Defaults to
552
+ # ./self-heals/heal.log next to the heal store.
553
+ # HEAL_LOG_DISABLED Set to "true" to skip the audit log entirely.
554
+ # ENV / HELIX_ENV When either is set, the heal store path is auto-
555
+ # suffixed with the env slug — `healed-locators.json`
556
+ # → `healed-locators.qa4.json`. Stops parallel runs
557
+ # against different test envs from trampling each
558
+ # other's healed selectors.
559
+ # ENABLE_LOCATOR_VERSIONING "true" (default) keeps the full healingHistory[]
560
+ # per locator. "false" keeps only the latest heal
561
+ # entry (smaller file).
562
+ # HEAL_HISTORY_CAP Cap on healingHistory length when versioning is on
563
+ # (default 50; set to 0 for unlimited).
564
+ # HEAL_STORE_PATH=./self-heals/healed-locators.json
565
+ # HEAL_LOG_PATH=./self-heals/heal.log
566
+ # ENABLE_LOCATOR_VERSIONING=true
567
+ # HEAL_HISTORY_CAP=50
568
+
569
+
570
+ # ── LocatorHealer chain tuning ───────────────────────────────────────────────
571
+ # LOCATOR_TIMEOUT Per-strategy attach-probe timeout (ms). Overrides
572
+ # the default 3000ms. Higher values give slow pages
573
+ # more headroom; lower values fail faster.
574
+ # LOCATOR_HEAL_ATTEMPTS Max probes the resolver runs across the chain
575
+ # before falling through to AI Vision. 0 = unlimited
576
+ # (default, original behaviour).
577
+ # HEAL_STRATEGY_ORDER CSV of strategy names to run, in order. Valid names:
578
+ # attribute, type-hint, role, label, text
579
+ # Default order is the same five strategies left-to-
580
+ # right. Omit a name to disable it, or reorder to
581
+ # prioritise a strategy your app favours.
582
+ # LOCATOR_TIMEOUT=3000
583
+ # LOCATOR_HEAL_ATTEMPTS=0
584
+ # HEAL_STRATEGY_ORDER=attribute,type-hint,role,label,text
585
+
586
+
587
+ # ── Visual Regression Testing ────────────────────────────────────────────────
588
+ # VRT_MODE baseline | test — overridden by `npm run vrt:*`
589
+ # VRT_SPRINT sprint identifier appended to baseline filenames
590
+ # VRT_THRESHOLD pixelmatch threshold (0.0–1.0, default 0.1)
591
+ # VRT_FAILURE_THRESHOLD percentage of diff pixels above which a test fails
592
+ # VRT_STABILISATION_DELAY ms to wait before screenshot (default 500)
593
+ # VRT_MODE=test
594
+ # VRT_SPRINT=
595
+ # VRT_THRESHOLD=0.1
596
+ # VRT_FAILURE_THRESHOLD=1.0
597
+ # VRT_STABILISATION_DELAY=500
598
+
599
+
600
+ # ── Browser / runtime flags ──────────────────────────────────────────────────
601
+ # Usually set by the npm script via cross-env. These are fallbacks for ad-hoc runs.
602
+ # APP=amplify # amplify | parishsoft
603
+ # ENV=dev1 # dev1 | test1 | stg | prod | localhost
604
+ # BROWSER=chrome # chrome | firefox | webkit | edge
605
+ # DOCKER=false
606
+ # CI=false
607
+ """
608
+
609
+ # Single-source-of-truth env.util.ts template. The migration tool always
610
+ # replaces the migrated copy with this so the loader semantics match the
611
+ # generated .env.example.
612
+ _ENV_UTIL_TEMPLATE = '''\
613
+ import * as dotenv from "dotenv";
614
+ import * as path from "path";
615
+
616
+ /**
617
+ * Single source of truth: project-root `.env`.
618
+ *
619
+ * Per-app values live in the same file with `_<APP>` suffixes
620
+ * (e.g. DOMAIN_AMPLIFY=..., DOMAIN_PARISHSOFT=...). After loading, the
621
+ * APP-suffixed variant for the active app is resolved into its canonical
622
+ * unsuffixed name (DOMAIN, HEADLESS, ...) so existing helpers keep working.
623
+ *
624
+ * Cross-env / shell-exported values are NEVER overridden — they win over
625
+ * the .env file in all cases.
626
+ */
627
+ const _APP_SCOPED_VARS = ["DOMAIN", "HEADLESS"] as const;
628
+
629
+ export const getEnvFile = (): void => {
630
+ dotenv.config({ path: path.resolve(process.cwd(), ".env"), override: false });
631
+
632
+ if (!process.env.ENV) {
633
+ console.error("NO ENV PASSED!");
634
+ }
635
+
636
+ const app = (process.env.APP || "amplify").toUpperCase();
637
+ for (const key of _APP_SCOPED_VARS) {
638
+ const scoped = `${key}_${app}`;
639
+ if (process.env[scoped] !== undefined && !process.env[key]) {
640
+ process.env[key] = process.env[scoped];
641
+ }
642
+ }
643
+ };
644
+ '''
645
+
646
+
647
+ def _patch_environment_util(root: Path) -> None:
648
+ """
649
+ Replace `src/helper/environment/environment.util.ts` with the
650
+ single-source-of-truth loader template.
651
+
652
+ Idempotent: skips files already matching the template.
653
+ """
654
+ target = root / "src" / "helper" / "environment" / "environment.util.ts"
655
+ if not target.exists():
656
+ return
657
+ try:
658
+ existing = target.read_text(encoding="utf-8")
659
+ except Exception:
660
+ return
661
+ if existing == _ENV_UTIL_TEMPLATE:
662
+ return
663
+ target.write_text(_ENV_UTIL_TEMPLATE, encoding="utf-8")
664
+
665
+
666
+ def _harvest_per_app_env(source_dir: str) -> list[tuple[str, dict[str, str]]]:
667
+ """
668
+ Find any `.env.<APP>` files in the source tree and return their values as
669
+ [(app, {KEY: VALUE, ...}), ...]. App is upper-cased.
670
+ Used to fold legacy per-app env into the single-file .env.example.
671
+ """
672
+ src = Path(source_dir)
673
+ if not src.exists():
674
+ return []
675
+ results: list[tuple[str, dict[str, str]]] = []
676
+ for p in src.rglob(".env.*"):
677
+ if not p.is_file():
678
+ continue
679
+ app = p.name[len(".env."):]
680
+ if not app or app == "example":
681
+ continue
682
+ try:
683
+ text = p.read_text(encoding="utf-8")
684
+ except Exception:
685
+ continue
686
+ kv: dict[str, str] = {}
687
+ for line in text.splitlines():
688
+ stripped = line.strip()
689
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
690
+ continue
691
+ k, _, v = stripped.partition("=")
692
+ kv[k.strip()] = v.strip()
693
+ if kv:
694
+ results.append((app.upper(), kv))
695
+ return results
696
+
697
+
698
+ def _scaffold_locators(
699
+ root: Path,
700
+ written: list[dict],
701
+ conflict_strategy: str = "interactive",
702
+ ) -> None:
703
+ """
704
+ Emit the LocatorHealer / LocatorRepository / TimingHealer / VisualIntentChecker
705
+ / HealingDashboard scaffolds into src/utils/locators/.
706
+
707
+ All migrated page objects + step defs reference these modules — without the
708
+ scaffold, the project fails to compile with TS2307 "Cannot find module
709
+ '@utils/locators/...'".
710
+
711
+ Conflict handling:
712
+ • "overwrite" — replace every scaffold file with the freshly generated
713
+ version. Lets users pick up generator improvements
714
+ (new env-var wiring, expanded strategy chain, etc.).
715
+ • "skip" — only write scaffold files that are missing.
716
+ • "interactive" — same as "skip" but appended to the conflicts list so
717
+ the caller can decide per-file.
718
+
719
+ Defaults are conservative: AI vision + timing healing + visual checker all on,
720
+ repository persisted under ./self-heals/healed-locators.json, dashboard on port 7890.
721
+ """
722
+ try:
723
+ from stlc_agents.agent_playwright_generator.server import _scaffold_locator_repository
724
+ except Exception:
725
+ return
726
+
727
+ try:
728
+ result = _scaffold_locator_repository(
729
+ output_dir="src/utils/locators",
730
+ enable_ai_vision=True,
731
+ repository_path="./self-heals/healed-locators.json",
732
+ dashboard_port=7890,
733
+ enable_timing_healing=True,
734
+ enable_visual_regression=True,
735
+ )
736
+ except Exception:
737
+ return
738
+
739
+ for rel_path, body in result.get("files", {}).items():
740
+ target = root / rel_path
741
+ already_exists = target.exists()
742
+ if already_exists and conflict_strategy != "overwrite":
743
+ # Skip (matches the historical "missing-only" behaviour). The
744
+ # caller stays in charge of when to refresh the scaffold.
745
+ continue
746
+ target.parent.mkdir(parents=True, exist_ok=True)
747
+ target.write_text(body, encoding="utf-8")
748
+ written.append({
749
+ "source": "(scaffold)",
750
+ "target": rel_path,
751
+ "role": "scaffold",
752
+ "change_count": 0,
753
+ })
754
+
755
+
756
+ def _write_root_env_example(
757
+ root: Path,
758
+ written: list[dict],
759
+ source_dir: str | None = None,
760
+ conflict_strategy: str = "interactive",
761
+ ) -> None:
762
+ """
763
+ Emit a single `.env.example` at the helix-qa root.
764
+
765
+ If the source project has per-app `.env.<APP>` files, their values are
766
+ folded into the template as `KEY_<APP>` entries — preserving the existing
767
+ project's app-specific config in the single new file.
768
+
769
+ Conflict handling matches the main migration loop:
770
+ • "overwrite" — refresh the file so users pick up new template keys
771
+ (e.g. new healing env vars).
772
+ • else — skip when the file already exists.
773
+ """
774
+ target = root / ".env.example"
775
+ if target.exists() and conflict_strategy != "overwrite":
776
+ return
777
+
778
+ per_app_section = ""
779
+ if source_dir:
780
+ harvested = _harvest_per_app_env(source_dir)
781
+ if harvested:
782
+ # Collect every key seen across all apps so we can group nicely.
783
+ apps_sorted = sorted({app for app, _ in harvested})
784
+ keys_sorted: list[str] = []
785
+ seen: set[str] = set()
786
+ for _, kv in harvested:
787
+ for k in kv:
788
+ if k not in seen:
789
+ keys_sorted.append(k)
790
+ seen.add(k)
791
+
792
+ lines = ["# ── Per-app overrides ────────────────────────────────────────────────────────",
793
+ "# Resolved to the canonical name (DOMAIN, HEADLESS, ...) by environment.util.ts",
794
+ "# based on the active APP. Each row is grouped by the original variable name.",
795
+ ""]
796
+ for k in keys_sorted:
797
+ for app in apps_sorted:
798
+ val = dict(harvested).get(app, {}).get(k)
799
+ if val is not None:
800
+ lines.append(f"{k}_{app}={val}")
801
+ lines.append("")
802
+ per_app_section = "\n".join(lines).rstrip() + "\n\n"
803
+
804
+ body = _ENV_EXAMPLE_HEADER + "\n" + per_app_section + _ENV_EXAMPLE_TAIL
805
+ target.write_text(body, encoding="utf-8")
806
+ written.append({
807
+ "source": "(generated)",
808
+ "target": ".env.example",
809
+ "role": "env",
810
+ "change_count": 0,
811
+ })
812
+
813
+
814
+ # ---------------------------------------------------------------------------
815
+ # Post-pass: fix locator .selector accesses
816
+ # ---------------------------------------------------------------------------
817
+
818
+ def _scaffold_browser_globals(root: Path) -> None:
819
+ """
820
+ Walk every migrated .ts file, find `page.evaluate(...)` callbacks, and
821
+ collect any bare identifier function calls inside them. These reference
822
+ browser-context functions defined by the SUT — TypeScript can't see them
823
+ without ambient declarations. Emit `src/types/browser-globals.d.ts` with
824
+ `declare const <name>: any;` for each name.
825
+
826
+ Idempotent: rewrites the file each run with the union of discovered names.
827
+ """
828
+ import re as _re
829
+
830
+ # Recurse over .ts files under the migrated tree (skip node_modules etc.).
831
+ skip_dirs = {"node_modules", "dist", "build", ".git", "test-results"}
832
+ files: list[Path] = []
833
+ for ts_file in root.rglob("*.ts"):
834
+ if any(s in ts_file.parts for s in skip_dirs):
835
+ continue
836
+ files.append(ts_file)
837
+ if not files:
838
+ return
839
+
840
+ # Always include Playwright Test runner fixtures that the converted steps
841
+ # may reference (request, testInfo) — there's no clean Cucumber equivalent
842
+ # to inject them, but declaring them as `any` makes tsc happy and the user
843
+ # can wire them through later.
844
+ seed_names: set[str] = {"request", "testInfo"}
845
+
846
+ # Collect names called as functions inside page.evaluate callbacks. We use
847
+ # a balanced-paren scan to extract the callback text, then look for bare
848
+ # `<ident>(` calls in it.
849
+ evaluate_open_re = _re.compile(r"\.evaluate\s*\(")
850
+ call_re = _re.compile(r"\b([A-Za-z_$][\w$]*)\s*\(")
851
+ builtin_blacklist = {
852
+ "if", "for", "while", "return", "switch", "case", "throw", "new",
853
+ "typeof", "instanceof", "delete", "void", "await", "async", "function",
854
+ "Array", "Object", "Math", "JSON", "Promise", "Date", "Number", "String",
855
+ "Boolean", "RegExp", "Error", "Map", "Set", "Symbol", "console",
856
+ "window", "document", "globalThis", "parseInt", "parseFloat",
857
+ "isNaN", "isFinite", "encodeURIComponent", "decodeURIComponent",
858
+ "setTimeout", "setInterval", "clearTimeout", "clearInterval",
859
+ "fetch", "btoa", "atob", "alert", "confirm", "prompt",
860
+ "Array", "Object", "Boolean", "Number", "String", "Symbol",
861
+ "Reflect", "Proxy", "WeakMap", "WeakSet", "DataView",
862
+ "Int8Array", "Uint8Array", "Float32Array", "Float64Array",
863
+ }
864
+
865
+ names: set[str] = set(seed_names)
866
+ for f in files:
867
+ try:
868
+ txt = f.read_text(encoding="utf-8")
869
+ except Exception:
870
+ continue
871
+ # Walk each .evaluate( call; extract its callback body via paren-counting.
872
+ for m in evaluate_open_re.finditer(txt):
873
+ paren_open = m.end() - 1
874
+ depth = 1
875
+ i = paren_open + 1
876
+ in_str: str | None = None
877
+ n = len(txt)
878
+ while i < n and depth > 0:
879
+ c = txt[i]
880
+ if in_str:
881
+ if c == "\\":
882
+ i += 2; continue
883
+ if c == in_str:
884
+ in_str = None
885
+ i += 1; continue
886
+ if c in ("'", '"', "`"):
887
+ in_str = c
888
+ i += 1; continue
889
+ if c == "(":
890
+ depth += 1
891
+ elif c == ")":
892
+ depth -= 1
893
+ i += 1
894
+ if depth != 0:
895
+ continue
896
+ callback = txt[paren_open + 1 : i - 1]
897
+ for cm in call_re.finditer(callback):
898
+ name = cm.group(1)
899
+ if name in builtin_blacklist:
900
+ continue
901
+ # Skip names that are clearly local: declared with `function`,
902
+ # `let`, `const`, `var` immediately preceding them, OR named
903
+ # exports / class member calls (`.foo()`).
904
+ start = cm.start()
905
+ # Skip member-access calls — `foo.bar(`.
906
+ if start > 0 and callback[start - 1] == ".":
907
+ continue
908
+ names.add(name)
909
+
910
+ if not names:
911
+ return
912
+
913
+ types_dir = root / "src" / "types"
914
+ types_dir.mkdir(parents=True, exist_ok=True)
915
+ out_path = types_dir / "browser-globals.d.ts"
916
+ body = "// Auto-generated by stlc-migrate.\n"
917
+ body += "// Ambient declarations for identifiers referenced inside\n"
918
+ body += "// page.evaluate(...) callbacks. These functions live in the\n"
919
+ body += "// browser context (defined by the SUT), not the Node test code,\n"
920
+ body += "// so TypeScript can't see them — declare them as `any` shims.\n"
921
+ body += "//\n"
922
+ body += "// Safe to edit: replace `any` with real signatures, or remove\n"
923
+ body += "// entries whose underlying call sites have been rewritten.\n\n"
924
+ for name in sorted(names):
925
+ body += f"declare const {name}: any;\n"
926
+ out_path.write_text(body, encoding="utf-8")
927
+
928
+
929
+ def _scaffold_page_fixture(root: Path) -> None:
930
+ """
931
+ Drop a minimal `src/hooks/pageFixture.ts` (and matching Before/After hooks
932
+ in `src/hooks/hooks.ts`) when neither already exists. Generated step files
933
+ import `fixture` from `@hooks/pageFixture`; without this scaffold those
934
+ imports fail to resolve.
935
+
936
+ Idempotent: skips both files if they already exist.
937
+ """
938
+ hooks_dir = root / "src" / "hooks"
939
+ fixture_path = hooks_dir / "pageFixture.ts"
940
+ hooks_path = hooks_dir / "hooks.ts"
941
+ if fixture_path.exists() and hooks_path.exists():
942
+ return
943
+ hooks_dir.mkdir(parents=True, exist_ok=True)
944
+ if not fixture_path.exists():
945
+ fixture_path.write_text(
946
+ 'import { Page, Browser, BrowserContext } from "@playwright/test";\n'
947
+ "\n"
948
+ "export interface PageFixture {\n"
949
+ " page: Page;\n"
950
+ " browser?: Browser;\n"
951
+ " context?: BrowserContext;\n"
952
+ " logger?: { info: (m: string) => void; error: (m: string) => void };\n"
953
+ "}\n"
954
+ "\n"
955
+ "let _current: PageFixture | undefined;\n"
956
+ "\n"
957
+ "export function setFixture(f: PageFixture): void {\n"
958
+ " _current = f;\n"
959
+ "}\n"
960
+ "\n"
961
+ "export function fixture(): PageFixture {\n"
962
+ ' if (!_current) throw new Error("PageFixture not initialised — Before hook must call setFixture(...)");\n'
963
+ " return _current;\n"
964
+ "}\n",
965
+ encoding="utf-8",
966
+ )
967
+ if not hooks_path.exists():
968
+ hooks_path.write_text(
969
+ 'import { Before, After, BeforeAll, AfterAll } from "@cucumber/cucumber";\n'
970
+ 'import { chromium, Browser, Page } from "@playwright/test";\n'
971
+ 'import { setFixture } from "./pageFixture";\n'
972
+ "\n"
973
+ "let browser: Browser;\n"
974
+ "let page: Page;\n"
975
+ "\n"
976
+ "BeforeAll(async () => {\n"
977
+ " browser = await chromium.launch({ headless: process.env.HEADLESS !== 'false' });\n"
978
+ "});\n"
979
+ "\n"
980
+ "Before(async function () {\n"
981
+ " const context = await browser.newContext();\n"
982
+ " page = await context.newPage();\n"
983
+ " setFixture({ page, browser, context });\n"
984
+ "});\n"
985
+ "\n"
986
+ "After(async function () {\n"
987
+ " await page.context().close();\n"
988
+ "});\n"
989
+ "\n"
990
+ "AfterAll(async () => {\n"
991
+ " await browser.close();\n"
992
+ "});\n",
993
+ encoding="utf-8",
994
+ )
995
+
996
+
997
+ def _ensure_tsconfig(root: Path) -> None:
998
+ """
999
+ If the migrated tree has no tsconfig.json (e.g. when the source was a
1000
+ pure-JS Playwright project), write a minimal one that wires the same
1001
+ Helix path aliases used elsewhere in the migrator. Idempotent: no-op
1002
+ when a tsconfig already exists.
1003
+ """
1004
+ target = root / "tsconfig.json"
1005
+ if target.exists():
1006
+ return
1007
+ cfg = {
1008
+ "compilerOptions": {
1009
+ "target": "ES2021",
1010
+ "module": "commonjs",
1011
+ "lib": ["dom", "es2021"],
1012
+ "esModuleInterop": True,
1013
+ "resolveJsonModule": True,
1014
+ "skipLibCheck": True,
1015
+ # Pragmatic defaults for a synthesised tsconfig — most source
1016
+ # projects we migrate are JS-flavoured Playwright/cucumber repos
1017
+ # that wouldn't survive `strict: true`. The user can tighten any
1018
+ # of these once they've added explicit types.
1019
+ "strict": False,
1020
+ "noImplicitAny": False,
1021
+ "strictNullChecks": False,
1022
+ "allowJs": True,
1023
+ "checkJs": False,
1024
+ "baseUrl": "src",
1025
+ "paths": {
1026
+ "@pages/*": ["pages/*"],
1027
+ "@steps/*": ["test/steps/*"],
1028
+ "@features/*": ["test/features/*"],
1029
+ "@locators/*": ["locators/*"],
1030
+ "@config/*": ["config/*"],
1031
+ "@utils/*": ["utils/*"],
1032
+ "@helper/*": ["helper/*"],
1033
+ "@hooks/*": ["hooks/*"],
1034
+ },
1035
+ },
1036
+ # The migrated tree may have raw-copied helpers/page_objects/scripts
1037
+ # at the project root (not under src/), so use a broad include with an
1038
+ # explicit exclude list rather than the default `["src"]`.
1039
+ "include": ["**/*.ts", "**/*.js"],
1040
+ "exclude": ["node_modules", "dist", "build", "test-results"],
1041
+ "ts-node": {
1042
+ "transpileOnly": True,
1043
+ "require": ["tsconfig-paths/register"],
1044
+ },
1045
+ }
1046
+ import json as _json
1047
+ target.write_text(_json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
1048
+
1049
+
1050
+ def _flush_hoist_buffer(root: Path, hoist_buffer: dict[str, list[HoistAction]]) -> None:
1051
+ """
1052
+ Append buffered HoistActions to the .locators.ts file(s) on disk that
1053
+ contain the matching xxxLocators block.
1054
+
1055
+ Strategy: walk every .locators.ts under <root>/src/locators/, look for
1056
+ `export const xxxLocators = {` blocks, and pass through any actions
1057
+ whose `locator_object` matches.
1058
+ """
1059
+ if not hoist_buffer:
1060
+ return
1061
+
1062
+ locator_dir = root / "src" / "locators"
1063
+ if not locator_dir.exists():
1064
+ return
1065
+
1066
+ for locator_path in locator_dir.glob("*.ts"):
1067
+ try:
1068
+ content = locator_path.read_text(encoding="utf-8")
1069
+ except Exception:
1070
+ continue
1071
+ # Skip files that don't define any of our target xxxLocators.
1072
+ relevant = {
1073
+ name: actions
1074
+ for name, actions in hoist_buffer.items()
1075
+ if f"export const {name}" in content
1076
+ }
1077
+ if not relevant:
1078
+ continue
1079
+ new_content, _changes = append_entries_to_locator_file(content, relevant)
1080
+ if new_content != content:
1081
+ try:
1082
+ locator_path.write_text(new_content, encoding="utf-8")
1083
+ except Exception:
1084
+ pass
1085
+
1086
+
1087
+ def _fix_selector_accesses_in_tree(root: Path, written: list[dict]) -> None:
1088
+ """
1089
+ After all files are written, collect structured keys from locator files and
1090
+ rewrite page_object / step_def files so that `locatorGroup.key` becomes
1091
+ `locatorGroup.key.selector` wherever the key is now a { selector, intent, stability } object.
1092
+ """
1093
+ # Collect object keys from all written locator files
1094
+ object_keys_by_name: dict[str, set[str]] = {}
1095
+ for entry in written:
1096
+ if entry.get("role") != "locator":
1097
+ continue
1098
+ locator_path = root / entry["target"]
1099
+ try:
1100
+ lc = locator_path.read_text(encoding="utf-8")
1101
+ object_keys_by_name.update(collect_object_keys(lc))
1102
+ except Exception:
1103
+ pass
1104
+
1105
+ if not object_keys_by_name:
1106
+ return
1107
+
1108
+ # Apply fixes to every TS/JS code role that may reference locators
1109
+ _FIXABLE_ROLES = {"page_object", "step_def", "helper", "hook", "fixture", "support", "api_client"}
1110
+ for entry in written:
1111
+ if entry.get("role") not in _FIXABLE_ROLES:
1112
+ continue
1113
+ target_path = root / entry["target"]
1114
+ try:
1115
+ content = target_path.read_text(encoding="utf-8")
1116
+ new_content, changes = fix_selector_accesses(content, object_keys_by_name)
1117
+ if changes:
1118
+ target_path.write_text(new_content, encoding="utf-8")
1119
+ entry["change_count"] = entry.get("change_count", 0) + len(changes)
1120
+ except Exception:
1121
+ pass
1122
+
1123
+
1124
+ # ---------------------------------------------------------------------------
1125
+ # Fill missing files from agent_helix_writer's BOILERPLATE
1126
+ # ---------------------------------------------------------------------------
1127
+
1128
+ # Allow-list of BOILERPLATE entries that drop cleanly into a migrated tree
1129
+ # without depending on APIs the user's existing source code may not expose.
1130
+ # Everything else is held back because:
1131
+ #
1132
+ # • `src/pages/BasePage.ts` / `src/templates/_TemplatePage.ts` — assume a
1133
+ # `fixture.locatorRepository` property and a 3-arg LocatorHealer
1134
+ # constructor that the enhanced scaffold doesn't expose.
1135
+ # • `src/utils/locators/index.ts` / `PlaywrightHealerLogger.ts` /
1136
+ # `LocatorManager.ts` — re-export `HealerLogger` / `HealEvent` symbols
1137
+ # that exist only in the BOILERPLATE scaffold variant, not in the
1138
+ # enhanced one written by `_scaffold_locator_repository`.
1139
+ # • `src/utils/ai-assistant/*`, `src/utils/storage-state/*`,
1140
+ # `src/utils/helpers/*`, `src/config/*` — depend on the `@config/*`
1141
+ # tsconfig path mapping pointing at `src/config`, but most existing
1142
+ # migrated trees have `@config/*` pointing at `../config` (root-level).
1143
+ # Filling them in would surface a wall of TS2307 module-not-found
1144
+ # errors instead of "all features working".
1145
+ #
1146
+ # Files held back are listed in MIGRATION-REPORT.md so the operator knows
1147
+ # they're available and what adaptation is needed before enabling them.
1148
+ _BOILERPLATE_FILL_ALLOWLIST = frozenset({
1149
+ # Base locator file referenced by generated page objects from
1150
+ # qa-playwright-generator. Pure addition; doesn't conflict.
1151
+ "src/locators/base.locators.ts",
1152
+ # Healing-infrastructure layer the agents reference but the enhanced
1153
+ # scaffold doesn't replace — pure additions, each independently
1154
+ # compilable against the enhanced LocatorHealer / LocatorRepository.
1155
+ "src/utils/locators/ElementContextHelper.ts",
1156
+ "src/utils/locators/HealApplicator.ts",
1157
+ "src/utils/locators/LocatorRules.ts",
1158
+ "src/utils/locators/LocatorStrategy.ts",
1159
+ "src/utils/locators/healix-ci-apply.ts",
1160
+ "src/utils/locators/review-server.ts",
1161
+ # Helpers the locator-strategy layer depends on. Logger imports the
1162
+ # config module via a *relative* path, so we ship environment.ts in
1163
+ # the same drop — without depending on any `@config/*` tsconfig alias.
1164
+ "src/config/environment.ts",
1165
+ "src/utils/helpers/Logger.ts",
1166
+ "src/utils/helpers/RetryHandler.ts",
1167
+ "src/utils/helpers/WaitHelper.ts",
1168
+ })
1169
+
1170
+
1171
+ def _fill_helix_boilerplate(
1172
+ root: Path,
1173
+ written: list[dict],
1174
+ conflict_strategy: str,
1175
+ ) -> None:
1176
+ """
1177
+ Drop in every file the qa-helix-writer / qa-playwright-generator agents
1178
+ expect a fully-populated Helix-QA tree to contain — base classes,
1179
+ AI-assistant helpers, locator strategy primitives, auth setup, retry /
1180
+ wait / logger utilities, templates, example specs.
1181
+
1182
+ Source of truth: `agent_helix_writer.tools.boilerplate.BOILERPLATE` (the
1183
+ same map the agents use when bootstrapping a fresh project). The
1184
+ migration's own scaffold takes precedence for files we've enhanced
1185
+ (LocatorHealer, LocatorRepository, TimingHealer, VisualIntentChecker,
1186
+ HealingDashboard, dashboard-server) — those land first and we never
1187
+ overwrite them with the BOILERPLATE version.
1188
+
1189
+ Top-level config files (package.json, tsconfig.json, .env, etc.) are
1190
+ skipped here — they're already produced by the merge passes.
1191
+
1192
+ Honors `--conflict overwrite` for everything else: missing files always
1193
+ get written; existing files are only refreshed when overwrite is on.
1194
+ """
1195
+ try:
1196
+ from stlc_agents.agent_helix_writer.tools.boilerplate import BOILERPLATE
1197
+ except Exception:
1198
+ # Boilerplate package not importable — nothing to fill.
1199
+ return
1200
+
1201
+ # FILL-ONLY: never overwrite an existing file regardless of
1202
+ # conflict_strategy. Only files in the allowlist are even considered,
1203
+ # because the broader BOILERPLATE set contains components that assume
1204
+ # API surfaces the user's migrated code doesn't expose.
1205
+ for rel_path, body in BOILERPLATE.items():
1206
+ if rel_path not in _BOILERPLATE_FILL_ALLOWLIST:
1207
+ continue
1208
+ target = root / rel_path
1209
+ if target.exists():
1210
+ continue
1211
+ target.parent.mkdir(parents=True, exist_ok=True)
1212
+ target.write_text(body, encoding="utf-8")
1213
+ written.append({
1214
+ "source": "(boilerplate) agent_helix_writer",
1215
+ "target": rel_path,
1216
+ "role": "boilerplate",
1217
+ "change_count": 0,
1218
+ })
1219
+
1220
+
1221
+ # ---------------------------------------------------------------------------
1222
+ # Agent-asset installation (skills, .mcp.json, governance docs)
1223
+ # ---------------------------------------------------------------------------
1224
+
1225
+ _ADO_SKILL_DIRS = (
1226
+ "generate-test-cases",
1227
+ "generate-gherkin",
1228
+ "generate-playwright-code",
1229
+ "write-helix-files",
1230
+ "deduplication-protocol",
1231
+ "migrate-framework",
1232
+ )
1233
+ _JIRA_SKILL_DIRS = (
1234
+ "qa-jira-manager",
1235
+ "generate-gherkin",
1236
+ "generate-playwright-code",
1237
+ "write-helix-files",
1238
+ "deduplication-protocol",
1239
+ "migrate-framework",
1240
+ )
1241
+
1242
+
1243
+ def _stlc_agents_root() -> Path:
1244
+ """
1245
+ Absolute path to the stlc-agents package install — used to locate the
1246
+ skill source tree, governance docs, and agent binaries. We resolve from
1247
+ *this* file's location so the same code works whether stlc-agents is
1248
+ installed editable (`pip install -e .`) or from a wheel.
1249
+ """
1250
+ # _migrate.py lives at <root>/src/stlc_agents/agent_migration/_migrate.py
1251
+ return Path(__file__).resolve().parents[3]
1252
+
1253
+
1254
+ def _agent_binary_path(name: str) -> str:
1255
+ """
1256
+ Locate an installed agent's console-script binary. Falls back to a bare
1257
+ name (which only works when the user has stlc-agents' venv on PATH) so
1258
+ the .mcp.json is always emittable.
1259
+ """
1260
+ venv_bin = Path(sys.executable).parent
1261
+ candidate = venv_bin / name
1262
+ return str(candidate) if candidate.exists() else name
1263
+
1264
+
1265
+ def _install_agent_assets(
1266
+ root: Path,
1267
+ written: list[dict],
1268
+ integration: str,
1269
+ conflict_strategy: str,
1270
+ ) -> None:
1271
+ """
1272
+ Drop in the assets every QA-STLC agent expects to find in a Helix-QA tree:
1273
+
1274
+ • `.claude/skills/<name>/SKILL.md` per integration (ado / jira / both)
1275
+ • `.claude/AGENT-BEHAVIOR.md` zero-inference contract
1276
+ • `AGENT-BEHAVIOR.md` (root) readable from the project root
1277
+ • `ORCHESTRATION_RULES.md` multi-step workflow rules (if present)
1278
+ • `.mcp.json` MCP server registry — Claude Code,
1279
+ Cursor, and other MCP-aware clients
1280
+ auto-discover agents from this file
1281
+ • `.github/copilot-instructions/` same skills, flattened for VS Code
1282
+
1283
+ Behavior is gated on conflict_strategy:
1284
+ "overwrite" → always refresh (picks up tool-side improvements)
1285
+ else → only write missing files
1286
+ """
1287
+ stlc_root = _stlc_agents_root()
1288
+ skills_dir = stlc_root / "skills"
1289
+ if not skills_dir.exists():
1290
+ # stlc-agents not installed editable & wheel didn't ship skills/ —
1291
+ # nothing to do (no warning since this path is rare).
1292
+ return
1293
+
1294
+ overwrite = conflict_strategy == "overwrite"
1295
+ chosen: tuple[str, ...]
1296
+ if integration == "jira":
1297
+ chosen = _JIRA_SKILL_DIRS
1298
+ elif integration == "ado":
1299
+ chosen = _ADO_SKILL_DIRS
1300
+ else: # "both" / default
1301
+ chosen = tuple(dict.fromkeys((*_ADO_SKILL_DIRS, *_JIRA_SKILL_DIRS)))
1302
+
1303
+ # 1. Claude Code: nested SKILL.md inside .claude/skills/<name>/
1304
+ claude_skills = root / ".claude" / "skills"
1305
+ claude_skills.mkdir(parents=True, exist_ok=True)
1306
+ for name in chosen:
1307
+ src = skills_dir / name / "SKILL.md"
1308
+ if not src.exists():
1309
+ continue
1310
+ dst = claude_skills / name / "SKILL.md"
1311
+ if dst.exists() and not overwrite:
1312
+ continue
1313
+ dst.parent.mkdir(parents=True, exist_ok=True)
1314
+ shutil.copyfile(src, dst)
1315
+ written.append({
1316
+ "source": f"(stlc-agents) skills/{name}/SKILL.md",
1317
+ "target": str(dst.relative_to(root)),
1318
+ "role": "skill",
1319
+ "change_count": 0,
1320
+ })
1321
+
1322
+ # 2. VS Code: flat .github/copilot-instructions/<name>.md
1323
+ vscode_skills = root / ".github" / "copilot-instructions"
1324
+ vscode_skills.mkdir(parents=True, exist_ok=True)
1325
+ for name in chosen:
1326
+ src = skills_dir / name / "SKILL.md"
1327
+ if not src.exists():
1328
+ continue
1329
+ dst = vscode_skills / f"{name}.md"
1330
+ if dst.exists() and not overwrite:
1331
+ continue
1332
+ shutil.copyfile(src, dst)
1333
+ written.append({
1334
+ "source": f"(stlc-agents) skills/{name}/SKILL.md",
1335
+ "target": str(dst.relative_to(root)),
1336
+ "role": "skill",
1337
+ "change_count": 0,
1338
+ })
1339
+
1340
+ # 3. Governance docs (AGENT-BEHAVIOR.md, ORCHESTRATION_RULES.md)
1341
+ for doc_name in ("AGENT-BEHAVIOR.md", "ORCHESTRATION_RULES.md"):
1342
+ src = stlc_root / doc_name
1343
+ # AGENT-BEHAVIOR.md actually lives under skills/ — try both paths.
1344
+ if not src.exists():
1345
+ src = skills_dir / doc_name
1346
+ if not src.exists():
1347
+ continue
1348
+ for dst in (root / doc_name, root / ".claude" / doc_name,
1349
+ root / ".github" / "copilot-instructions" / doc_name):
1350
+ if dst.exists() and not overwrite:
1351
+ continue
1352
+ dst.parent.mkdir(parents=True, exist_ok=True)
1353
+ shutil.copyfile(src, dst)
1354
+ written.append({
1355
+ "source": f"(stlc-agents) {src.relative_to(stlc_root)}",
1356
+ "target": str(dst.relative_to(root)),
1357
+ "role": "agent-doc",
1358
+ "change_count": 0,
1359
+ })
1360
+
1361
+ # 4. .mcp.json — MCP server registry (Claude Code / Cursor pick this up)
1362
+ mcp_path = root / ".mcp.json"
1363
+ if not mcp_path.exists() or overwrite:
1364
+ mcp_path.write_text(_render_mcp_config(integration), encoding="utf-8")
1365
+ written.append({
1366
+ "source": "(stlc-agents) auto-generated",
1367
+ "target": ".mcp.json",
1368
+ "role": "mcp-config",
1369
+ "change_count": 0,
1370
+ })
1371
+
1372
+
1373
+ def _render_mcp_config(integration: str) -> str:
1374
+ """
1375
+ Emit a .mcp.json listing the agent MCP servers for the selected
1376
+ integration. Always includes the shared agents (gherkin / playwright /
1377
+ helix / migration) and the Playwright MCP. ADO/Jira-specific entries
1378
+ are added based on `integration`.
1379
+ """
1380
+ servers: dict[str, dict] = {
1381
+ "qa-gherkin-generator": {"command": _agent_binary_path("qa-gherkin-generator")},
1382
+ "qa-playwright-generator": {"command": _agent_binary_path("qa-playwright-generator")},
1383
+ "qa-helix-writer": {"command": _agent_binary_path("qa-helix-writer")},
1384
+ "qa-migration": {"command": _agent_binary_path("stlc-migrate")},
1385
+ "playwright": {"command": "npx", "args": ["@playwright/mcp@latest", "--isolated"]},
1386
+ }
1387
+ if integration in ("ado", "both"):
1388
+ servers["qa-test-case-manager"] = {"command": _agent_binary_path("qa-test-case-manager")}
1389
+ if integration in ("jira", "both"):
1390
+ servers["qa-jira-manager"] = {
1391
+ "command": _agent_binary_path("qa-jira-manager"),
1392
+ "env": {
1393
+ "JIRA_CLIENT_ID": "${JIRA_CLIENT_ID}",
1394
+ "JIRA_CLIENT_SECRET": "${JIRA_CLIENT_SECRET}",
1395
+ "JIRA_CLOUD_ID": "${JIRA_CLOUD_ID}",
1396
+ },
1397
+ }
1398
+ return json.dumps({"mcpServers": servers}, indent=2) + "\n"