@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,439 @@
1
+ """Map source test files to their roles and Helix-QA target paths."""
2
+ from __future__ import annotations
3
+ import re
4
+ from collections import defaultdict
5
+ from pathlib import Path
6
+
7
+
8
+ _SKIP_DIRS = {
9
+ "node_modules", "dist", ".venv", "__pycache__", ".git",
10
+ "build", "coverage", ".next", "out", ".nyc_output",
11
+ "test-results", "playwright-report", "allure-results", "allure-report",
12
+ }
13
+
14
+ # Fix 1: removed *constants* — config/enum files are not locators
15
+ _LOCATOR_RE = [
16
+ re.compile(r".*[Ll]ocators?\.(js|ts)$"),
17
+ re.compile(r".*[Ss]electors?\.(js|ts)$"),
18
+ re.compile(r".*\.locators?\.(js|ts)$"),
19
+ ]
20
+
21
+ _PAGE_OBJ_RE = [
22
+ re.compile(r".*[Pp]age\.(js|ts)$"),
23
+ re.compile(r".*\.page\.(js|ts)$"),
24
+ re.compile(r".*[Pp]age[Oo]bject\.(js|ts)$"),
25
+ re.compile(r".*[Pp][Oo]\.(js|ts)$"),
26
+ ]
27
+
28
+ _STEP_DEF_RE = [
29
+ re.compile(r".*\.steps\.(js|ts)$"),
30
+ re.compile(r".*[Ss]teps?\.(js|ts)$"),
31
+ re.compile(r".*(step[_-]?definitions?|stepDefinitions?)/.*\.(js|ts)$", re.IGNORECASE),
32
+ ]
33
+
34
+ _FEATURE_RE = [re.compile(r".*\.feature$")]
35
+
36
+ _CONFIG_RE = {
37
+ "config_playwright": [re.compile(r"playwright\.config\.(js|ts)$")],
38
+ "config_cucumber": [re.compile(r"cucumber\.(js|cjs|mjs)$")],
39
+ "config_tsconfig": [re.compile(r"tsconfig(\..+)?\.json$")],
40
+ "config_package": [re.compile(r"package\.json$")],
41
+ }
42
+
43
+ # ── Helpers / utilities ─────────────────────────────────────────────────────
44
+ # Anything under a helper/helpers/utils/util directory or matching .helper / .util suffix.
45
+ # Includes .json/.txt data files inside the helper tree so test data migrates too.
46
+ _HELPER_RE = [
47
+ re.compile(r"(?:^|.*[\\/])(helpers?|utils?)[\\/].*\.(js|ts|json|txt)$"),
48
+ re.compile(r".*\.helper\.(js|ts)$"),
49
+ re.compile(r".*\.util\.(js|ts)$"),
50
+ re.compile(r".*\.utils\.(js|ts)$"),
51
+ ]
52
+
53
+ # ── API clients (commonly under pages/.../api/ in older layouts) ────────────
54
+ # These behave like helpers — pure utility/page classes — so we route them under src/helper/api/
55
+ _API_CLIENT_RE = [
56
+ re.compile(r"(?:^|.*[\\/])api[\\/][^\\/]+\.api\.(js|ts)$"),
57
+ re.compile(r".*\.api\.(js|ts)$"),
58
+ ]
59
+
60
+ # ── Hooks (Cucumber + Playwright fixtures) ─────────────────────────────────
61
+ # pageFixture, Before/After hooks, World definitions
62
+ _HOOK_RE = [
63
+ re.compile(r"(?:^|.*[\\/])hooks?[\\/].*\.(js|ts)$"),
64
+ re.compile(r".*pageFixture\.(js|ts)$", re.IGNORECASE),
65
+ re.compile(r".*[\\/](world|Before|After)\.(js|ts)$"),
66
+ ]
67
+
68
+ # ── Fixtures (Playwright fixtures, test data) ──────────────────────────────
69
+ _FIXTURE_RE = [
70
+ re.compile(r"(?:^|.*[\\/])fixtures?[\\/].*\.(js|ts|json)$"),
71
+ re.compile(r".*\.fixture\.(js|ts)$"),
72
+ ]
73
+
74
+ # ── Cucumber support files (World, custom params) ──────────────────────────
75
+ _SUPPORT_RE = [
76
+ re.compile(r".*[\\/]support[\\/].*\.(js|ts)$"),
77
+ ]
78
+
79
+ # ── Docker / containerisation ──────────────────────────────────────────────
80
+ _DOCKER_RE = [
81
+ re.compile(r"(.*[\\/])?Dockerfile(\.[A-Za-z0-9_-]+)?$"),
82
+ re.compile(r"(.*[\\/])?docker-compose([.-][A-Za-z0-9_-]+)?\.(ya?ml)$"),
83
+ re.compile(r"(.*[\\/])?\.dockerignore$"),
84
+ ]
85
+
86
+ # ── Environment files ──────────────────────────────────────────────────────
87
+ _ENV_RE = [
88
+ re.compile(r"(.*[\\/])?\.env(\.[A-Za-z0-9_-]+)?$"),
89
+ re.compile(r"(.*[\\/])?\.env\.example$"),
90
+ ]
91
+
92
+ # ── CI / CD pipelines ──────────────────────────────────────────────────────
93
+ _CI_RE = [
94
+ re.compile(r"\.github[\\/]workflows[\\/].*\.(ya?ml)$"),
95
+ re.compile(r"\.gitlab-ci\.(ya?ml)$"),
96
+ re.compile(r"azure-pipelines(\.[A-Za-z0-9_-]+)?\.(ya?ml)$"),
97
+ re.compile(r"bitbucket-pipelines\.(ya?ml)$"),
98
+ re.compile(r"\.circleci[\\/]config\.(ya?ml)$"),
99
+ ]
100
+
101
+ # ── Scripts (build / utility) ──────────────────────────────────────────────
102
+ # Match scripts/foo.sh and bare run_qa.sh at the project root alike.
103
+ _SCRIPT_RE = [
104
+ re.compile(r"(^|.*[\\/])scripts?[\\/].*\.(sh|ts|js|py)$"),
105
+ re.compile(r"^[^\\/]+\.sh$"), # any shell script at the project root
106
+ ]
107
+
108
+ # ── Playwright spec files (non-BDD source) ────────────────────────────────
109
+ # `tests/**/*.spec.js` / `*.spec.ts` / `*.test.js` / `*.test.ts` from a
110
+ # conventional Playwright project. These get split into a generated Gherkin
111
+ # feature file plus a Cucumber step definition file, so the migrated project
112
+ # is BDD-shaped even when the source was not.
113
+ _PLAYWRIGHT_SPEC_RE = [
114
+ re.compile(r"(^|.*[\\/])tests?[\\/].*\.(spec|test)\.(js|ts)$"),
115
+ re.compile(r"^[^\\/]+\.(spec|test)\.(js|ts)$"), # spec file at the project root
116
+ ]
117
+
118
+ # Structural path segments that carry no meaningful domain information
119
+ _STRUCTURAL = {
120
+ "src", "test", "tests", "steps", "features", "pages", "locators",
121
+ "utils", "helper", "helpers", "hooks", "config", "fixtures",
122
+ "support", "types", "interfaces", "models", "spec", "specs",
123
+ }
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Kebab helpers
128
+ # ---------------------------------------------------------------------------
129
+
130
+ def _to_kebab(stem: str) -> str:
131
+ """Convert a filename stem to kebab-case, stripping known role suffixes."""
132
+ for suffix in ("Page", "Locators", "Selectors", "Steps", "PageObject", "PO"):
133
+ if stem.endswith(suffix):
134
+ stem = stem[: -len(suffix)]
135
+ stem = re.sub(r"([A-Z])", r"-\1", stem).lstrip("-").lower()
136
+ stem = re.sub(r"[_\s]+", "-", stem)
137
+ stem = re.sub(r"-{2,}", "-", stem).strip("-")
138
+ return stem or "unknown"
139
+
140
+
141
+ def _dir_to_kebab(name: str) -> str:
142
+ """Convert a directory name to kebab-case (handles ALL_CAPS and CamelCase)."""
143
+ if name.isupper():
144
+ return name.lower()
145
+ result = re.sub(r"([A-Z])", r"-\1", name).lstrip("-").lower()
146
+ result = re.sub(r"[_\s]+", "-", result)
147
+ result = re.sub(r"-{2,}", "-", result).strip("-")
148
+ return result or name.lower()
149
+
150
+
151
+ # Fix 3: CamelCase-aware kebab for feature filenames
152
+ def _feature_kebab(stem: str) -> str:
153
+ """Convert a feature filename stem to kebab-case, preserving word boundaries."""
154
+ # Insert hyphen before each uppercase letter sequence start
155
+ result = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", stem)
156
+ result = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", result)
157
+ result = result.lower()
158
+ result = re.sub(r"[^a-z0-9]+", "-", result).strip("-")
159
+ return result or "unknown"
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Role detection
164
+ # ---------------------------------------------------------------------------
165
+
166
+ def _detect_role(rel: str) -> str:
167
+ # Order matters — more specific patterns first.
168
+ for pat in _LOCATOR_RE:
169
+ if pat.search(rel):
170
+ return "locator"
171
+ for pat in _PAGE_OBJ_RE:
172
+ if pat.search(rel):
173
+ return "page_object"
174
+ for pat in _STEP_DEF_RE:
175
+ if pat.search(rel):
176
+ return "step_def"
177
+ for pat in _FEATURE_RE:
178
+ if pat.search(rel):
179
+ return "feature"
180
+ # Playwright spec files take priority over the catch-all "unknown" role
181
+ # so the migrator can split them into feature+steps pairs.
182
+ for pat in _PLAYWRIGHT_SPEC_RE:
183
+ if pat.search(rel):
184
+ return "playwright_spec"
185
+ for role, patterns in _CONFIG_RE.items():
186
+ for pat in patterns:
187
+ if pat.search(rel):
188
+ return role
189
+ # Newly supported categories
190
+ for pat in _HOOK_RE:
191
+ if pat.search(rel):
192
+ return "hook"
193
+ for pat in _SUPPORT_RE:
194
+ if pat.search(rel):
195
+ return "support"
196
+ for pat in _FIXTURE_RE:
197
+ if pat.search(rel):
198
+ return "fixture"
199
+ for pat in _API_CLIENT_RE:
200
+ if pat.search(rel):
201
+ return "api_client"
202
+ for pat in _HELPER_RE:
203
+ if pat.search(rel):
204
+ return "helper"
205
+ for pat in _DOCKER_RE:
206
+ if pat.search(rel):
207
+ return "docker"
208
+ for pat in _ENV_RE:
209
+ if pat.search(rel):
210
+ return "env"
211
+ for pat in _CI_RE:
212
+ if pat.search(rel):
213
+ return "ci"
214
+ for pat in _SCRIPT_RE:
215
+ if pat.search(rel):
216
+ return "script"
217
+ return "unknown"
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Target path computation
222
+ # ---------------------------------------------------------------------------
223
+
224
+ def _helix_target(path: Path, role: str, rel: str | None = None) -> str | None:
225
+ stem = path.stem
226
+ rel = rel or path.as_posix()
227
+
228
+ if role == "locator":
229
+ stem = re.sub(r"\.locators?$", "", stem, flags=re.IGNORECASE)
230
+ return f"src/locators/{_to_kebab(stem)}.locators.ts"
231
+
232
+ if role == "page_object":
233
+ stem = re.sub(r"\.page$", "", stem, flags=re.IGNORECASE)
234
+ return f"src/pages/{_to_kebab(stem)}.page.ts"
235
+
236
+ if role == "step_def":
237
+ stem = re.sub(r"\.steps?$", "", stem, flags=re.IGNORECASE)
238
+ return f"src/test/steps/{_to_kebab(stem)}.steps.ts"
239
+
240
+ if role == "playwright_spec":
241
+ # Strip .spec/.test suffixes from the stem; the generated steps file
242
+ # is written here, and the companion .feature file is derived from
243
+ # this path during migration (see _migrate.py).
244
+ stem = re.sub(r"\.(spec|test)$", "", stem, flags=re.IGNORECASE)
245
+ return f"src/test/steps/{_to_kebab(stem)}.steps.ts"
246
+
247
+ if role == "feature":
248
+ # Fix 3: CamelCase-aware conversion
249
+ return f"src/test/features/{_feature_kebab(path.stem)}.feature"
250
+
251
+ if role == "config_playwright":
252
+ return "playwright.config.ts"
253
+
254
+ if role == "config_cucumber":
255
+ # Emitted as CJS .js at root (not TS under src/) — cucumber-js can't
256
+ # load a .ts config without prior ts-node bootstrap. See
257
+ # config_merger.merge_cucumber_config.
258
+ return "config/cucumber.js"
259
+
260
+ if role == "config_package":
261
+ return "package.json"
262
+
263
+ if role == "config_tsconfig":
264
+ return "tsconfig.json"
265
+
266
+ # ── Helper: preserve sub-tree under src/helper/ ──────────────────────────
267
+ if role == "helper":
268
+ return f"src/helper/{_preserve_under(rel, ('helper', 'helpers', 'utils', 'util'))}"
269
+
270
+ # ── API client: flatten to src/helper/api/ ───────────────────────────────
271
+ if role == "api_client":
272
+ return f"src/helper/api/{path.name}"
273
+
274
+ # ── Hook: flat under src/hooks/ ──────────────────────────────────────────
275
+ if role == "hook":
276
+ return f"src/hooks/{path.name}"
277
+
278
+ # ── Fixture: preserve sub-tree under src/fixtures/ ───────────────────────
279
+ if role == "fixture":
280
+ return f"src/fixtures/{_preserve_under(rel, ('fixture', 'fixtures'))}"
281
+
282
+ # ── Support (Cucumber): flat under src/test/support/ ─────────────────────
283
+ if role == "support":
284
+ return f"src/test/support/{path.name}"
285
+
286
+ # ── Docker / env / scripts / CI: keep at original location relative to root
287
+ if role == "docker":
288
+ return path.name if "/" not in rel else rel # keep at root, or preserve dir
289
+ if role == "env":
290
+ # If env file sits at the project root, target the project root.
291
+ # Otherwise preserve its nested location — many frameworks load
292
+ # `.env.<app>` from a fixed sub-path (e.g. src/helper/environment/).
293
+ if "/" not in rel:
294
+ return path.name
295
+ return rel
296
+ if role == "ci":
297
+ return rel # preserve `.github/workflows/...` etc.
298
+ if role == "script":
299
+ # Preserve location: scripts/foo.sh stays under scripts/, but root-level
300
+ # run_qa.sh stays at root.
301
+ return rel
302
+
303
+ # ── Catch-all: any file not matched by a specific role is raw-copied to
304
+ # the same relative path it had in the source project. This keeps READMEs,
305
+ # docs (.md), license files, .gitignore, .editorconfig, vrt-baselines,
306
+ # arbitrary JSON data, etc. — anything the user dropped in the source —
307
+ # available in the migrated project.
308
+ if role == "unknown":
309
+ return rel
310
+
311
+ return None
312
+
313
+
314
+ def _preserve_under(rel: str, parents: tuple[str, ...]) -> str:
315
+ """
316
+ Given a relative path like 'src/helper/dashboard/validation.helper.ts' and
317
+ parents like ('helper', 'helpers'), return the sub-path beneath the first
318
+ matching parent — e.g. 'dashboard/validation.helper.ts'.
319
+ Falls back to the filename if no parent matches.
320
+ """
321
+ parts = Path(rel).parts
322
+ for i, p in enumerate(parts):
323
+ if p.lower() in parents:
324
+ sub = "/".join(parts[i + 1 :])
325
+ return sub or parts[-1]
326
+ return parts[-1] if parts else rel
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # Fix 2: Collision disambiguation
331
+ # ---------------------------------------------------------------------------
332
+
333
+ def _pick_prefix(rel: str, target_stem: str) -> str:
334
+ """
335
+ Walk the source path right-to-left and return the first directory segment
336
+ that is (a) not the target stem itself and (b) not a generic structural dir.
337
+ Falls back to any non-matching segment if all non-structural ones match.
338
+ """
339
+ parts = list(Path(rel).parts[:-1]) # directory parts only
340
+
341
+ # First pass: prefer non-structural, non-matching segments
342
+ for p in reversed(parts):
343
+ k = _dir_to_kebab(p)
344
+ if k and k != target_stem and k not in _STRUCTURAL:
345
+ return k
346
+
347
+ # Second pass: take any non-matching segment
348
+ for p in reversed(parts):
349
+ k = _dir_to_kebab(p)
350
+ if k and k != target_stem:
351
+ return k
352
+
353
+ return _dir_to_kebab(parts[-1]) if parts else "misc"
354
+
355
+
356
+ def _disambiguate(mappings: list[dict]) -> list[dict]:
357
+ """
358
+ Detect target collisions and add a distinguishing directory prefix to
359
+ each colliding file's target path. Runs up to 3 passes.
360
+ """
361
+ for _pass in range(3):
362
+ groups: dict[str, list[int]] = defaultdict(list)
363
+ for i, m in enumerate(mappings):
364
+ if m["target"]:
365
+ groups[m["target"]].append(i)
366
+
367
+ any_collision = False
368
+ for target, indices in groups.items():
369
+ if len(indices) <= 1:
370
+ continue
371
+ any_collision = True
372
+
373
+ target_path = Path(target)
374
+ target_dir = str(target_path.parent)
375
+ target_filename = target_path.name # e.g. "organization.steps.ts"
376
+ target_stem = target_filename.split(".")[0] # e.g. "organization"
377
+
378
+ for idx in indices:
379
+ m = mappings[idx]
380
+ prefix = _pick_prefix(m["relative"], target_stem)
381
+ m["target"] = f"{target_dir}/{prefix}-{target_filename}"
382
+
383
+ if not any_collision:
384
+ break
385
+
386
+ return mappings
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # Public API
391
+ # ---------------------------------------------------------------------------
392
+
393
+ def map_source_files(source_dir: str) -> list[dict]:
394
+ """
395
+ Scan source_dir and return a list of file descriptors.
396
+
397
+ Each entry:
398
+ source — absolute path
399
+ relative — path relative to source_dir (forward slashes)
400
+ role — locator | page_object | step_def | feature |
401
+ config_playwright | config_cucumber | unknown
402
+ target — Helix-relative target path, or None
403
+ stem — filename stem
404
+ needs_js_to_ts — True when source is .js and target is .ts
405
+ """
406
+ root = Path(source_dir)
407
+ results: list[dict] = []
408
+
409
+ for path in sorted(root.rglob("*")):
410
+ if not path.is_file():
411
+ continue
412
+ if any(s in path.parts for s in _SKIP_DIRS):
413
+ continue
414
+ # Skip OS / editor cruft that adds no value to the migrated project.
415
+ if path.name in {".DS_Store", "Thumbs.db"}:
416
+ continue
417
+
418
+ rel = path.relative_to(root).as_posix()
419
+
420
+ # No extension filter — every surviving file is migrated. Files that
421
+ # don't match any specific role land in role="unknown" and are raw-copied
422
+ # at their original relative path by _helix_target / _migrate.py.
423
+
424
+ role = _detect_role(rel)
425
+ tgt = _helix_target(path, role, rel)
426
+
427
+ results.append({
428
+ "source": str(path),
429
+ "relative": rel,
430
+ "role": role,
431
+ "target": tgt,
432
+ "stem": path.stem,
433
+ "needs_js_to_ts": path.suffix == ".js" and tgt is not None and tgt.endswith(".ts"),
434
+ })
435
+
436
+ # Fix 2: resolve target-path collisions
437
+ _disambiguate(results)
438
+
439
+ return results
@@ -0,0 +1,86 @@
1
+ """Generate MIGRATION-REPORT.md after a migration run."""
2
+ from __future__ import annotations
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+
7
+ def generate_report(
8
+ helix_root: str,
9
+ source_dir: str,
10
+ framework: str,
11
+ written: list[dict],
12
+ skipped: list[dict],
13
+ conflicts: list[dict],
14
+ todos: list[str],
15
+ ) -> str:
16
+ """Write MIGRATION-REPORT.md to helix_root and return the absolute path."""
17
+ now = datetime.now().strftime("%Y-%m-%d %H:%M")
18
+
19
+ lines = [
20
+ "# Migration Report",
21
+ "",
22
+ f"**Generated:** {now} ",
23
+ f"**Source:** `{source_dir}` ",
24
+ f"**Framework:** `{framework}` ",
25
+ f"**Helix root:** `{helix_root}`",
26
+ "",
27
+ "---",
28
+ "",
29
+ "## Summary",
30
+ "",
31
+ "| Metric | Count |",
32
+ "|--------|-------|",
33
+ f"| Files written | {len(written)} |",
34
+ f"| Files skipped | {len(skipped)} |",
35
+ f"| Conflicts resolved | {len(conflicts)} |",
36
+ f"| TODOs added | {len(todos)} |",
37
+ "",
38
+ ]
39
+
40
+ if written:
41
+ lines += [
42
+ "## Migrated Files",
43
+ "",
44
+ "| Source | Target | Role | Changes |",
45
+ "|--------|--------|------|---------|",
46
+ ]
47
+ for f in written:
48
+ src = f.get("source", "—")
49
+ tgt = f.get("target", "—")
50
+ role = f.get("role", "—")
51
+ chg = f.get("change_count", 0)
52
+ lines.append(f"| `{src}` | `{tgt}` | {role} | {chg} |")
53
+ lines.append("")
54
+
55
+ if todos:
56
+ lines += ["## TODOs", ""]
57
+ for t in todos:
58
+ lines.append(f"- {t}")
59
+ lines.append("")
60
+
61
+ if skipped:
62
+ lines += ["## Skipped Files", ""]
63
+ for f in skipped:
64
+ src = f.get("source", "—")
65
+ reason = f.get("reason", "unknown")
66
+ lines.append(f"- `{src}` — {reason}")
67
+ lines.append("")
68
+
69
+ if conflicts:
70
+ lines += ["## Conflicts Resolved", ""]
71
+ for c in conflicts:
72
+ path = c.get("path", "—")
73
+ resolution = c.get("resolution", "unknown")
74
+ lines.append(f"- `{path}` — {resolution}")
75
+ lines.append("")
76
+
77
+ lines += [
78
+ "---",
79
+ "",
80
+ "> Generated by `qa-migration` — STLC Agents migration tool.",
81
+ "",
82
+ ]
83
+
84
+ report_path = Path(helix_root) / "MIGRATION-REPORT.md"
85
+ report_path.write_text("\n".join(lines), encoding="utf-8")
86
+ return str(report_path)