@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.
- package/ARCHITECTURE-ADO.md +350 -0
- package/ARCHITECTURE-JIRA.md +203 -0
- package/QUICKSTART-ADO.md +400 -0
- package/QUICKSTART-JIRA.md +334 -0
- package/README.md +49 -0
- package/bin/postinstall.js +14 -4
- package/package.json +19 -7
- package/skills/migrate-framework/SKILL.md +207 -0
- package/src/stlc_agents/agent_migration/__init__.py +0 -0
- package/src/stlc_agents/agent_migration/_migrate.py +1398 -0
- package/src/stlc_agents/agent_migration/cli.py +217 -0
- package/src/stlc_agents/agent_migration/detector.py +81 -0
- package/src/stlc_agents/agent_migration/mapper.py +439 -0
- package/src/stlc_agents/agent_migration/reporter.py +86 -0
- package/src/stlc_agents/agent_migration/server.py +267 -0
- package/src/stlc_agents/agent_migration/transformer/__init__.py +0 -0
- package/src/stlc_agents/agent_migration/transformer/config_merger.py +513 -0
- package/src/stlc_agents/agent_migration/transformer/healer_injector.py +1143 -0
- package/src/stlc_agents/agent_migration/transformer/import_fixer.py +419 -0
- package/src/stlc_agents/agent_migration/transformer/js_to_ts.py +413 -0
- package/src/stlc_agents/agent_migration/transformer/local_var_hoister.py +378 -0
- package/src/stlc_agents/agent_migration/transformer/locator_moderniser.py +132 -0
- package/src/stlc_agents/agent_migration/transformer/locator_registrar.py +328 -0
- package/src/stlc_agents/agent_migration/transformer/spec_to_bdd.py +820 -0
- package/src/stlc_agents/agent_playwright_generator/server.py +926 -91
- package/src/stlc_agents/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/auth.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-314.pyc +0 -0
- 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)
|