sp2react 0.1.0__py3-none-any.whl

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 (79) hide show
  1. sp2react/__init__.py +18 -0
  2. sp2react/agents/__init__.py +0 -0
  3. sp2react/agents/core.py +146 -0
  4. sp2react/agents/prompts/dbmap.md +28 -0
  5. sp2react/agents/prompts/fix.md +11 -0
  6. sp2react/agents/prompts/handler.md +22 -0
  7. sp2react/agents/prompts/page.md +40 -0
  8. sp2react/agents/prompts/review.md +21 -0
  9. sp2react/agents/prompts/spec.md +79 -0
  10. sp2react/agents/tasks.py +374 -0
  11. sp2react/analysis/__init__.py +0 -0
  12. sp2react/analysis/aspx.py +254 -0
  13. sp2react/analysis/behaviors.py +357 -0
  14. sp2react/analysis/fields.py +152 -0
  15. sp2react/analysis/inventory.py +314 -0
  16. sp2react/analysis/jsscan.py +330 -0
  17. sp2react/checks.py +105 -0
  18. sp2react/cli.py +188 -0
  19. sp2react/config.py +172 -0
  20. sp2react/dbmap.py +245 -0
  21. sp2react/llm/__init__.py +0 -0
  22. sp2react/llm/providers.py +219 -0
  23. sp2react/migrate.py +255 -0
  24. sp2react/net.py +60 -0
  25. sp2react/pipeline.py +583 -0
  26. sp2react/scaffold/__init__.py +0 -0
  27. sp2react/scaffold/generate.py +279 -0
  28. sp2react/scaffold/rule_vectors.json +3826 -0
  29. sp2react/scaffold/templates/backend/app/__init__.py +0 -0
  30. sp2react/scaffold/templates/backend/app/api.py +293 -0
  31. sp2react/scaffold/templates/backend/app/auth.py +33 -0
  32. sp2react/scaffold/templates/backend/app/custom/__init__.py +22 -0
  33. sp2react/scaffold/templates/backend/app/main.py +47 -0
  34. sp2react/scaffold/templates/backend/app/odata.py +185 -0
  35. sp2react/scaffold/templates/backend/app/rules.py +256 -0
  36. sp2react/scaffold/templates/backend/app/sp_repo.py +159 -0
  37. sp2react/scaffold/templates/backend/app/sql_repo.py +437 -0
  38. sp2react/scaffold/templates/backend/app/store.py +224 -0
  39. sp2react/scaffold/templates/backend/requirements.txt +8 -0
  40. sp2react/scaffold/templates/backend/tests/conftest.py +10 -0
  41. sp2react/scaffold/templates/backend/tests/test_rule_vectors.py +38 -0
  42. sp2react/scaffold/templates/frontend/index.html +12 -0
  43. sp2react/scaffold/templates/frontend/package.json +24 -0
  44. sp2react/scaffold/templates/frontend/scripts/check-rules.ts +23 -0
  45. sp2react/scaffold/templates/frontend/src/api.ts +88 -0
  46. sp2react/scaffold/templates/frontend/src/components/sp/Banner.tsx +4 -0
  47. sp2react/scaffold/templates/frontend/src/components/sp/DataTable.tsx +38 -0
  48. sp2react/scaffold/templates/frontend/src/components/sp/Form.tsx +252 -0
  49. sp2react/scaffold/templates/frontend/src/components/sp/GenericPage.tsx +28 -0
  50. sp2react/scaffold/templates/frontend/src/components/sp/Layout.tsx +54 -0
  51. sp2react/scaffold/templates/frontend/src/components/sp/LegacyHtml.tsx +9 -0
  52. sp2react/scaffold/templates/frontend/src/components/sp/ListView.tsx +59 -0
  53. sp2react/scaffold/templates/frontend/src/components/sp/Tiles.tsx +21 -0
  54. sp2react/scaffold/templates/frontend/src/components/sp/fields.tsx +132 -0
  55. sp2react/scaffold/templates/frontend/src/custom/types.ts +10 -0
  56. sp2react/scaffold/templates/frontend/src/main.tsx +14 -0
  57. sp2react/scaffold/templates/frontend/src/runtime/format.ts +36 -0
  58. sp2react/scaffold/templates/frontend/src/runtime/rules.ts +225 -0
  59. sp2react/scaffold/templates/frontend/src/runtime/spec.ts +102 -0
  60. sp2react/scaffold/templates/frontend/src/runtime/user.tsx +21 -0
  61. sp2react/scaffold/templates/frontend/src/screens/LegacyRedirect.tsx +27 -0
  62. sp2react/scaffold/templates/frontend/src/screens/ListFormScreen.tsx +33 -0
  63. sp2react/scaffold/templates/frontend/src/styles/sp.css +95 -0
  64. sp2react/scaffold/templates/frontend/src/vite-env.d.ts +1 -0
  65. sp2react/scaffold/templates/frontend/tsconfig.json +18 -0
  66. sp2react/scaffold/templates/frontend/vite.config.ts +7 -0
  67. sp2react/sharepoint/__init__.py +0 -0
  68. sp2react/sharepoint/client.py +210 -0
  69. sp2react/sharepoint/extract.py +217 -0
  70. sp2react/spec/__init__.py +0 -0
  71. sp2react/spec/base.py +80 -0
  72. sp2react/spec/coverage.py +191 -0
  73. sp2react/spec/model.py +133 -0
  74. sp2react-0.1.0.dist-info/METADATA +238 -0
  75. sp2react-0.1.0.dist-info/RECORD +79 -0
  76. sp2react-0.1.0.dist-info/WHEEL +5 -0
  77. sp2react-0.1.0.dist-info/entry_points.txt +2 -0
  78. sp2react-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. sp2react-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,314 @@
1
+ """Stage 3 (inspect): build the inventory for one screen.
2
+
3
+ A screen is either a list's forms taken together (NewForm/EditForm/DispForm share fields and usually
4
+ one script) or a standalone page (SitePages/Dashboard.aspx ...). The inventory is deterministic: it is
5
+ what the spec agent must account for, and what the coverage gate checks the spec against.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from .aspx import ParsedPage, load_page, resolve_tokens, scan_html
15
+ from .behaviors import INFO, MUST, Behavior, FieldIndex, analyze_script, classify_script
16
+ from .fields import map_fields, translate_formula
17
+
18
+ MODE_BY_FORM = {"newform": "new", "editform": "edit", "dispform": "display"}
19
+
20
+
21
+ def kebab(name: str) -> str:
22
+ s = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name)
23
+ return re.sub(r"[^a-zA-Z0-9]+", "-", s).strip("-").lower()
24
+
25
+
26
+ class Legacy:
27
+ """Read access to an extracted workspace (<workspace>/legacy)."""
28
+
29
+ def __init__(self, root: Path):
30
+ self.root = Path(root)
31
+ self.site = json.loads((self.root / "site.json").read_text(encoding="utf-8"))
32
+ self.prefix = self.site.get("serverRelativeUrl", "").rstrip("/")
33
+ self.lists = {l["url"].rstrip("/").split("/")[-1]: l for l in self.site["lists"]}
34
+ self.list_by_id = {l["id"].strip("{}").lower(): k for k, l in self.lists.items()}
35
+ self.list_by_title = {l["title"].lower(): k for k, l in self.lists.items()}
36
+ self._fields: dict[str, tuple[list[dict], list[dict]]] = {}
37
+ self.index = FieldIndex()
38
+ for key, l in self.lists.items():
39
+ flds, _ = self.fields(key)
40
+ self.index.add_list(key, l["title"], flds)
41
+
42
+ def fields(self, key: str) -> tuple[list[dict], list[dict]]:
43
+ if key not in self._fields:
44
+ f = self.root / "lists" / key / "fields.json"
45
+ self._fields[key] = map_fields(json.loads(f.read_text(encoding="utf-8")), self.list_by_id) if f.exists() else ([], [])
46
+ return self._fields[key]
47
+
48
+ def items(self, key: str) -> list[dict]:
49
+ f = self.root / "lists" / key / "items.json"
50
+ if not f.exists():
51
+ return []
52
+ data = json.loads(f.read_text(encoding="utf-8"))
53
+ return data.get("d", {}).get("results", []) if isinstance(data, dict) else data
54
+
55
+ def site_rel(self, url: str) -> str:
56
+ url = resolve_tokens(url, self.prefix).split("?")[0].split("#")[0]
57
+ url = re.sub(r"^https?://[^/]+", "", url)
58
+ if url.lower().startswith(self.prefix.lower() + "/"):
59
+ return url[len(self.prefix) + 1:]
60
+ return url.lstrip("/")
61
+
62
+ def file_text(self, rel: str) -> Optional[str]:
63
+ f = self.root / "files" / rel
64
+ return f.read_text(encoding="utf-8", errors="replace") if f.exists() else None
65
+
66
+ def pages(self) -> list[str]:
67
+ base = self.root / "pages"
68
+ return sorted(str(p.relative_to(base)).replace("\\", "/") for p in base.rglob("*.aspx"))
69
+
70
+ def form_list(self, page_rel: str, parsed: Optional[ParsedPage] = None) -> Optional[str]:
71
+ if parsed and parsed.list_id and parsed.list_id in self.list_by_id:
72
+ return self.list_by_id[parsed.list_id]
73
+ for key, l in self.lists.items():
74
+ for form in l.get("forms", {}).values():
75
+ if f"{l['url']}/{form}".lower() == page_rel.lower():
76
+ return key
77
+ return None
78
+
79
+
80
+ def screens(legacy: Legacy, only: Optional[list[str]] = None) -> list[dict]:
81
+ """Group pages into screens: one per list with forms, one per other page."""
82
+ out, used = [], set()
83
+ pages = legacy.pages()
84
+ wanted = None
85
+ if only:
86
+ wanted = {legacy.site_rel(p) if p.startswith(("/", "http")) else p for p in only}
87
+ for page in pages:
88
+ if page in used:
89
+ continue
90
+ parsed = load_page(legacy.root, page)
91
+ key = legacy.form_list(page, parsed)
92
+ if key:
93
+ l = legacy.lists[key]
94
+ members = [f"{l['url']}/{f}" for f in l.get("forms", {}).values() if f"{l['url']}/{f}" in pages] or [page]
95
+ used.update(members)
96
+ scr = {"id": f"{key}-form", "kind": "list-form", "list": key, "title": l["title"], "pages": members,
97
+ "route": f"/lists/{kebab(key)}"}
98
+ else:
99
+ used.add(page)
100
+ stem = page.rsplit("/", 1)[-1].rsplit(".", 1)[0]
101
+ scr = {"id": stem, "kind": "page", "list": None, "title": parsed.title or stem, "pages": [page],
102
+ "route": f"/pages/{kebab(stem)}"}
103
+ if wanted is None or wanted & set(scr["pages"]):
104
+ out.append(scr)
105
+ return out
106
+
107
+
108
+ def propose_endpoint(call: dict, legacy: Legacy) -> dict:
109
+ """Deterministic mapping of a legacy data call to the generated FastAPI contract."""
110
+ if call["type"] == "spservices":
111
+ op = call.get("operation", "")
112
+ if op in ("GetGroupCollectionFromUser", "GetUserProfileByName", "GetCurrentUser") or call.get("function") == "SPGetCurrentUser":
113
+ return {"method": "GET", "path": "/api/me", "purpose": "current user and roles (replaces SharePoint group lookup)"}
114
+ if call.get("function") == "SPCascadeDropdowns":
115
+ child = legacy.index.resolve(call.get("childColumn", ""))
116
+ lst, fld = (child[0].list, child[0].name) if child else ("?", "?")
117
+ return {"method": "GET", "path": f"/api/lists/{lst}/lookup/{fld}", "query": {"parent": "<parent id>"}, "purpose": "cascading choices"}
118
+ if op in ("GetListItems",):
119
+ key = legacy.list_by_title.get((call.get("listName") or "").lower(), call.get("listName"))
120
+ return {"method": "GET", "path": f"/api/lists/{key}/items", "query": {"caml": call.get("CAMLQuery"), "rowLimit": call.get("CAMLRowLimit")},
121
+ "purpose": "list query (CAML to be converted to the API filter)"}
122
+ if op in ("UpdateListItems",):
123
+ key = legacy.list_by_title.get((call.get("listName") or "").lower(), call.get("listName"))
124
+ return {"method": "POST|PATCH", "path": f"/api/lists/{key}/items", "purpose": "create/update via UpdateListItems"}
125
+ return {"method": "?", "path": None, "purpose": f"SPServices {op}: needs an agent decision"}
126
+ key = legacy.list_by_title.get((call.get("list") or "").lower()) or (legacy.list_by_id.get((call.get("list_id") or "").lower()))
127
+ if call.get("resource") == "user":
128
+ return {"method": "GET", "path": "/api/me", "purpose": "current user"}
129
+ if call.get("resource") != "list" or not key:
130
+ return {"method": call["method"], "path": None, "purpose": f"{call.get('resource', 'unknown')} endpoint: needs an agent decision"}
131
+ m = call["method"]
132
+ item = call.get("item")
133
+ base = f"/api/lists/{key}/items"
134
+ if m == "GET":
135
+ return {"method": "GET", "path": base + ("/{id}" if item else ""), "query": call.get("query", {}),
136
+ "purpose": "read item" if item else "query items"}
137
+ if m == "POST" and not item:
138
+ return {"method": "POST", "path": base, "body": call.get("body_fields", []), "purpose": "create item"}
139
+ if m in ("MERGE", "PATCH", "PUT") or (m == "POST" and item):
140
+ return {"method": "PATCH", "path": base + "/{id}", "body": call.get("body_fields", []), "if_match": True, "purpose": "update item"}
141
+ if m == "DELETE":
142
+ return {"method": "DELETE", "path": base + "/{id}", "purpose": "delete item"}
143
+ return {"method": m, "path": base, "purpose": "needs an agent decision"}
144
+
145
+
146
+ def build_inventory(legacy: Legacy, screen: dict) -> dict:
147
+ inv: dict = {"screen": screen, "site": {"url": legacy.site.get("url"), "title": legacy.site.get("title")},
148
+ "pages": [], "scripts": {"custom": [], "library": [], "sharepoint": [], "external": [], "missing": []},
149
+ "stylesheets": [], "html_fragments": [], "behaviors": [], "calls": [], "lists": {}, "roles": [],
150
+ "unresolved": [], "stats": {}}
151
+ prefer = screen.get("list")
152
+ analysed: dict[str, list[str]] = {} # script rel -> modes using it
153
+ behaviors: list[Behavior] = []
154
+ calls: list[dict] = []
155
+ rendered_counts = {"sharepoint": 0, "library": 0, "custom": 0, "external": 0}
156
+
157
+ for page in screen["pages"]:
158
+ parsed = load_page(legacy.root, page)
159
+ stem = page.rsplit("/", 1)[-1].rsplit(".", 1)[0].lower()
160
+ mode = (parsed.form_mode or "").lower() or MODE_BY_FORM.get(stem)
161
+ if mode not in ("new", "edit", "display"):
162
+ mode = None
163
+ inv["pages"].append({"path": page, "mode": mode, **parsed.to_dict()})
164
+ for css in parsed.stylesheets:
165
+ rel = legacy.site_rel(css)
166
+ if rel not in inv["stylesheets"] and not rel.startswith("_layouts"):
167
+ inv["stylesheets"].append(rel)
168
+ for frag in parsed.html_fragments:
169
+ inv["html_fragments"].append({"page": page, "mode": mode, **frag})
170
+ for wp in parsed.web_parts:
171
+ if wp.content_link:
172
+ rel = legacy.site_rel(wp.content_link)
173
+ txt = legacy.file_text(rel)
174
+ if txt is None:
175
+ inv["unresolved"].append({"kind": "missing_file", "detail": f"Content Editor link {rel} was not exported", "page": page})
176
+ continue
177
+ srcs, inline, css, stripped = scan_html(txt)
178
+ parsed.scripts += srcs
179
+ parsed.inline_scripts += [{"where": f"contentlink:{rel}", "code": c} for c in inline]
180
+ if stripped:
181
+ inv["html_fragments"].append({"page": page, "mode": mode, "web_part": wp.title, "source": rel, "html": stripped})
182
+ if wp.type == "ListView":
183
+ lk = legacy.list_by_id.get((wp.list_id or "").lower()) or (wp.list_url or "").split("/")[-1] or None
184
+ b = Behavior(id=f"{page}#webpart:{wp.title}:listview", kind="list_view", level=MUST, file=page, line=0,
185
+ code=f"List view web part '{wp.title}' on {lk}", detail={"list": lk, "view_xml": wp.view_xml, "zone": wp.zone})
186
+ behaviors.append(b)
187
+ for src in parsed.scripts:
188
+ rel = legacy.site_rel(src)
189
+ text = legacy.file_text(rel)
190
+ cls = classify_script(src, text)
191
+ bucket = inv["scripts"][cls]
192
+ if rel not in [s["path"] for s in bucket]:
193
+ bucket.append({"path": rel})
194
+ if cls != "custom":
195
+ continue
196
+ if text is None:
197
+ if rel not in inv["scripts"]["missing"]:
198
+ inv["scripts"]["missing"].append(rel)
199
+ inv["unresolved"].append({"kind": "missing_file", "detail": f"Custom script {rel} was referenced but not exported", "page": page})
200
+ continue
201
+ analysed.setdefault(rel, [])
202
+ if mode:
203
+ analysed[rel].append(mode)
204
+ if len(analysed[rel]) > 1 or (not mode and analysed[rel]):
205
+ continue
206
+ bs, cs = analyze_script(rel, text, legacy.index, prefer)
207
+ behaviors += bs
208
+ calls += cs
209
+ next(x for x in bucket if x["path"] == rel)["lines"] = text.count("\n") + 1
210
+ for n, inl in enumerate(parsed.inline_scripts, 1):
211
+ name = f"{page}#inline{n}"
212
+ bs, cs = analyze_script(name, inl["code"], legacy.index, prefer)
213
+ for b in bs:
214
+ b.detail.setdefault("mode", mode)
215
+ behaviors += bs
216
+ calls += cs
217
+ inv["scripts"]["custom"].append({"path": name, "lines": inl["code"].count("\n") + 1, "inline": True, "where": inl["where"], "modes": [mode] if mode else []})
218
+
219
+ # what the browser actually loads (master page / custom actions add scripts the .aspx never mentions)
220
+ rf = legacy.root / "rendered" / (re.sub(r"\.aspx$", "", page, flags=re.I) + ".html")
221
+ if rf.exists():
222
+ srcs, _, _, _ = scan_html(rf.read_text(encoding="utf-8", errors="replace"))
223
+ for s in srcs:
224
+ cls = classify_script(s, None)
225
+ rendered_counts[cls] += 1
226
+ rel = legacy.site_rel(s)
227
+ if cls == "custom" and rel not in analysed and rel not in [x["path"] for x in inv["scripts"]["custom"]]:
228
+ inv["unresolved"].append({"kind": "runtime_script", "detail": f"{rel} is loaded at runtime (master page or custom action) but not referenced by the page", "page": page})
229
+
230
+ for s in inv["scripts"]["custom"]:
231
+ if s["path"] in analysed:
232
+ s["modes"] = sorted(set(analysed[s["path"]]))
233
+
234
+ # ------------------------------------------------ list schema behaviours
235
+ involved: list[str] = []
236
+
237
+ def involve(key: Optional[str]) -> None:
238
+ if key and key in legacy.lists and key not in involved:
239
+ involved.append(key)
240
+
241
+ involve(prefer)
242
+ for c in calls:
243
+ involve(legacy.list_by_title.get((c.get("list") or c.get("listName") or c.get("relationshipList") or "").lower()))
244
+ for b in behaviors:
245
+ for f in b.fields:
246
+ involve(f.list)
247
+ if b.kind == "list_view":
248
+ involve(b.detail.get("list"))
249
+ for key in list(involved):
250
+ for f in legacy.fields(key)[0]:
251
+ if f.get("lookup"):
252
+ involve(f["lookup"]["list"])
253
+
254
+ for key in involved:
255
+ l = legacy.lists[key]
256
+ flds, formulas = legacy.fields(key)
257
+ names = {}
258
+ for f in flds:
259
+ names[f["label"]] = f["name"]
260
+ names[f["name"]] = f["name"]
261
+ names[f["label"].lower()] = f["name"]
262
+ ported = []
263
+ for fm in formulas:
264
+ rule = translate_formula(fm["formula"], names)
265
+ entry = {"field": fm["field"], "formula": fm["formula"], "message": fm["message"], "ported": rule}
266
+ ported.append(entry)
267
+ if key == prefer:
268
+ behaviors.append(Behavior(id=f"schema:{key}.{fm['field']}:column_validation", kind="column_validation", level=MUST,
269
+ file=f"lists/{key}/fields.json", line=0, code=fm["formula"], message=fm["message"],
270
+ detail={"ported": rule}))
271
+ lv = None
272
+ if l.get("validationFormula"):
273
+ lv = {"formula": l["validationFormula"], "message": l.get("validationMessage") or "",
274
+ "ported": translate_formula(l["validationFormula"], names)}
275
+ if key == prefer:
276
+ behaviors.append(Behavior(id=f"schema:{key}:list_validation", kind="list_validation", level=MUST,
277
+ file="site.json", line=0, code=l["validationFormula"], message=lv["message"], detail={"ported": lv["ported"]}))
278
+ if key == prefer and l.get("enableAttachments"):
279
+ behaviors.append(Behavior(id=f"schema:{key}:attachments", kind="attachments", level=MUST, file="site.json", line=0,
280
+ code="EnableAttachments=true", detail={}))
281
+ inv["lists"][key] = {"title": l["title"], "role": "primary" if key == prefer else "related", "fields": flds,
282
+ "column_validations": ported, "list_validation": lv,
283
+ "views": l.get("views", {}), "attachments": l.get("enableAttachments", False),
284
+ "item_count": len(legacy.items(key))}
285
+
286
+ # roles: SharePoint groups referenced in code, plus the site's groups for context
287
+ referenced = sorted({b.detail["group"] for b in behaviors if b.kind == "role_check"})
288
+ inv["roles"] = [{"group": g["name"], "members": len(g.get("members", [])), "referenced": g["name"] in referenced}
289
+ for g in legacy.site.get("groups", [])]
290
+ for g in referenced:
291
+ if g not in [r["group"] for r in inv["roles"]]:
292
+ inv["roles"].append({"group": g, "members": None, "referenced": True})
293
+
294
+ for c in calls:
295
+ if c.get("function") == "SPFindPeoplePicker":
296
+ c["proposed"] = {"method": "GET", "path": "/api/me", "purpose": "default a person field to the current user"}
297
+ continue
298
+ c["proposed"] = propose_endpoint(c, legacy)
299
+ if c["proposed"].get("path") is None:
300
+ inv["unresolved"].append({"kind": "endpoint", "detail": f"No standard endpoint for {c.get('url') or c.get('operation')}", "behavior": c["behavior"]})
301
+
302
+ inv["behaviors"] = [b.to_dict() for b in behaviors]
303
+ inv["calls"] = calls
304
+ inv["stats"] = {
305
+ "pages": len(inv["pages"]), "custom_scripts": len(inv["scripts"]["custom"]),
306
+ "library_scripts": len(inv["scripts"]["library"]), "behaviors_must": sum(1 for b in behaviors if b.level == MUST),
307
+ "behaviors_info": sum(1 for b in behaviors if b.level == INFO), "data_calls": len(calls),
308
+ "rendered_scripts": rendered_counts, "lists": len(involved), "unresolved": len(inv["unresolved"]),
309
+ }
310
+ return inv
311
+
312
+
313
+ def must_behaviors(inv: dict) -> list[dict]:
314
+ return [b for b in inv["behaviors"] if b["level"] == MUST]
@@ -0,0 +1,330 @@
1
+ """Lightweight JavaScript scanning for legacy SharePoint customisations.
2
+
3
+ Not a full parser: it tokenises strings/comments/regex literals well enough to
4
+ * know every string literal and its line,
5
+ * mask strings so structural regexes (braces, '+', ';') are safe,
6
+ * find function ranges, simple variable assignments and `$.ajax({...})` blocks,
7
+ * rebuild string-concatenation expressions as templates: "{VR.siteUrl}/_api/...(" + id + ")" -> "{VR.siteUrl}/_api/...({id})".
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Optional
14
+
15
+ REGEX_PRECEDERS = set("(,=:[!&|?{};+-*%<>~^") | {""}
16
+ REGEX_KEYWORDS = {"return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw"}
17
+
18
+
19
+ @dataclass
20
+ class Literal:
21
+ index: int
22
+ value: str
23
+ start: int
24
+ end: int
25
+ line: int
26
+
27
+
28
+ @dataclass
29
+ class FuncRange:
30
+ name: str
31
+ start: int # offset of the opening brace in code
32
+ end: int # offset of the closing brace
33
+ line_start: int
34
+ line_end: int
35
+
36
+
37
+ @dataclass
38
+ class Scan:
39
+ source: str
40
+ code: str # strings -> __S<n>__ placeholders, comments -> spaces; newlines preserved
41
+ literals: list[Literal]
42
+ lines: list[str] # original source lines
43
+ code_lines: list[str]
44
+ functions: list[FuncRange] = field(default_factory=list)
45
+ assignments: dict[str, tuple[str, int]] = field(default_factory=dict) # name -> (code expr, line)
46
+
47
+ def line_of(self, code_offset: int) -> int:
48
+ return self.code.count("\n", 0, code_offset) + 1
49
+
50
+ def lit(self, placeholder: str) -> Optional[Literal]:
51
+ m = re.fullmatch(r"__S(\d+)__", placeholder.strip())
52
+ return self.literals[int(m.group(1))] if m else None
53
+
54
+ def literals_in(self, code_fragment: str) -> list[Literal]:
55
+ return [self.literals[int(i)] for i in re.findall(r"__S(\d+)__", code_fragment)]
56
+
57
+ def restore(self, code_fragment: str) -> str:
58
+ return re.sub(r"__S(\d+)__", lambda m: _quote(self.literals[int(m.group(1))].value), code_fragment)
59
+
60
+ def enclosing(self, code_offset: int) -> list[FuncRange]:
61
+ """Functions containing the offset, innermost last."""
62
+ return sorted((f for f in self.functions if f.start <= code_offset <= f.end), key=lambda f: f.start)
63
+
64
+
65
+ def _quote(v: str) -> str:
66
+ return '"' + v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
67
+
68
+
69
+ def _unescape(raw: str) -> str:
70
+ out, i = [], 0
71
+ while i < len(raw):
72
+ c = raw[i]
73
+ if c == "\\" and i + 1 < len(raw):
74
+ n = raw[i + 1]
75
+ out.append({"n": "\n", "t": "\t", "r": "\r", "'": "'", '"': '"', "\\": "\\", "/": "/"}.get(n, n))
76
+ i += 2
77
+ continue
78
+ out.append(c)
79
+ i += 1
80
+ return "".join(out)
81
+
82
+
83
+ def tokenize(src: str) -> tuple[str, list[Literal]]:
84
+ code: list[str] = []
85
+ lits: list[Literal] = []
86
+ i, n, line = 0, len(src), 1
87
+ prev_sig = "" # last significant code char (for regex detection)
88
+ prev_word = ""
89
+ while i < n:
90
+ c = src[i]
91
+ if c == "\n":
92
+ code.append(c)
93
+ line += 1
94
+ i += 1
95
+ continue
96
+ if c == "/" and i + 1 < n and src[i + 1] == "/":
97
+ j = src.find("\n", i)
98
+ j = n if j == -1 else j
99
+ code.append(" " * (j - i))
100
+ i = j
101
+ continue
102
+ if c == "/" and i + 1 < n and src[i + 1] == "*":
103
+ j = src.find("*/", i + 2)
104
+ j = n if j == -1 else j + 2
105
+ chunk = src[i:j]
106
+ code.append(re.sub(r"[^\n]", " ", chunk))
107
+ line += chunk.count("\n")
108
+ i = j
109
+ continue
110
+ if c in "'\"`":
111
+ j, buf = i + 1, []
112
+ while j < n and src[j] != c:
113
+ if src[j] == "\\" and j + 1 < n:
114
+ buf.append(src[j:j + 2])
115
+ j += 2
116
+ continue
117
+ if src[j] == "\n" and c != "`":
118
+ break
119
+ buf.append(src[j])
120
+ j += 1
121
+ raw = "".join(buf)
122
+ idx = len(lits)
123
+ lits.append(Literal(idx, _unescape(raw), i, j + 1, line))
124
+ nl = raw.count("\n")
125
+ code.append(f"__S{idx}__" + "\n" * nl)
126
+ line += nl
127
+ i = j + 1
128
+ prev_sig, prev_word = "s", ""
129
+ continue
130
+ if c == "/" and (prev_sig in REGEX_PRECEDERS or prev_word in REGEX_KEYWORDS):
131
+ # regex literal: skip to the closing unescaped slash (respecting [...] classes)
132
+ j, in_class = i + 1, False
133
+ while j < n and src[j] != "\n":
134
+ ch = src[j]
135
+ if ch == "\\":
136
+ j += 2
137
+ continue
138
+ if ch == "[":
139
+ in_class = True
140
+ elif ch == "]":
141
+ in_class = False
142
+ elif ch == "/" and not in_class:
143
+ break
144
+ j += 1
145
+ j += 1
146
+ while j < n and src[j].isalpha():
147
+ j += 1
148
+ code.append("_RE_" + " " * max(0, (j - i) - 4) if (j - i) >= 4 else "_RE_")
149
+ i = j
150
+ prev_sig, prev_word = "r", ""
151
+ continue
152
+ code.append(c)
153
+ if not c.isspace():
154
+ if c.isalnum() or c in "_$":
155
+ m = re.match(r"[\w$]+", src[i:])
156
+ word = m.group(0)
157
+ code.append(word[1:])
158
+ i += len(word)
159
+ prev_sig, prev_word = "w", word
160
+ continue
161
+ prev_sig, prev_word = c, ""
162
+ i += 1
163
+ return "".join(code), lits
164
+
165
+
166
+ def match_brace(code: str, open_pos: int) -> int:
167
+ """Offset of the bracket closing the one at open_pos (code is already string-masked)."""
168
+ pairs = {"{": "}", "(": ")", "[": "]"}
169
+ opener = code[open_pos]
170
+ closer = pairs[opener]
171
+ depth = 0
172
+ for k in range(open_pos, len(code)):
173
+ ch = code[k]
174
+ if ch == opener:
175
+ depth += 1
176
+ elif ch == closer:
177
+ depth -= 1
178
+ if depth == 0:
179
+ return k
180
+ return len(code) - 1
181
+
182
+
183
+ FUNC_PATTERNS = [
184
+ re.compile(r"\bfunction\s+([\w$]+)\s*\([^)]*\)\s*\{"),
185
+ re.compile(r"([\w$.]+)\s*[:=]\s*function\s*[\w$]*\s*\([^)]*\)\s*\{"),
186
+ re.compile(r"\$\(\s*document\s*\)\s*\.ready\s*\(\s*function\s*\([^)]*\)\s*\{"),
187
+ re.compile(r"(?<![\w.])(?:\$|jQuery)\(\s*function\s*\([^)]*\)\s*\{"),
188
+ re.compile(r"\b(_spBodyOnLoadFunctionNames)\.push"),
189
+ ]
190
+
191
+
192
+ def scan(source: str) -> Scan:
193
+ code, lits = tokenize(source)
194
+ s = Scan(source=source, code=code, literals=lits, lines=source.splitlines(), code_lines=code.split("\n"))
195
+ for pat in FUNC_PATTERNS[:4]:
196
+ for m in pat.finditer(code):
197
+ brace = code.rfind("{", m.start(), m.end())
198
+ if brace < 0:
199
+ continue
200
+ end = match_brace(code, brace)
201
+ name = m.group(1) if m.groups() and m.group(1) else "document.ready"
202
+ s.functions.append(FuncRange(name, brace, end, s.line_of(brace), s.line_of(end)))
203
+ # simple assignments: name = <expr>; (expr on masked code, may span lines)
204
+ for m in re.finditer(r"(?:\b(?:var|let|const)\s+)?([\w$]+(?:\.[\w$]+)*)\s*=(?!=)\s*([^;{}]+?);", code):
205
+ name, expr = m.group(1), m.group(2).strip()
206
+ if name in ("if", "for", "while", "return"):
207
+ continue
208
+ s.assignments.setdefault(name, (expr, s.line_of(m.start())))
209
+ return s
210
+
211
+
212
+ # ------------------------------------------------------------ expressions
213
+ _ARGS = r"\((?:[^(){}]|\([^(){}]*\))*\)"
214
+ OPERAND = rf"(?:__S\d+__|[\w$]+(?:\.[\w$]+)*(?:{_ARGS})?(?:\.[\w$]+(?:{_ARGS})?)*)"
215
+ CONCAT = re.compile(rf"{OPERAND}(?:\s*\+\s*{OPERAND})*")
216
+
217
+
218
+ def split_plus(expr: str) -> list[str]:
219
+ parts, depth, cur = [], 0, []
220
+ for ch in expr:
221
+ if ch in "([{":
222
+ depth += 1
223
+ elif ch in ")]}":
224
+ depth -= 1
225
+ if ch == "+" and depth == 0:
226
+ parts.append("".join(cur))
227
+ cur = []
228
+ else:
229
+ cur.append(ch)
230
+ parts.append("".join(cur))
231
+ return [p.strip() for p in parts if p.strip()]
232
+
233
+
234
+ def template(scan_: Scan, expr: str, depth: int = 0) -> str:
235
+ """Rebuild a concatenation as text with {placeholders}; substitutes known variable templates."""
236
+ out = []
237
+ for part in split_plus(expr):
238
+ lit = scan_.lit(part)
239
+ if lit is not None:
240
+ out.append(lit.value)
241
+ continue
242
+ name = part.strip("() ")
243
+ if depth < 4 and name in scan_.assignments and re.fullmatch(r"[\w$.]+", name):
244
+ sub = scan_.assignments[name][0]
245
+ if "__S" in sub:
246
+ rendered = template(scan_, sub, depth + 1)
247
+ if re.search(r"[/?&]", rendered): # only inline URL-like fragments, keep other vars as {name}
248
+ out.append(rendered)
249
+ continue
250
+ out.append("{" + scan_.restore(part) + "}")
251
+ return "".join(out)
252
+
253
+
254
+ def concat_expressions(scan_: Scan) -> list[tuple[int, int, str]]:
255
+ """All maximal concatenation expressions, including those nested in call arguments: (start, end, code)."""
256
+ out: list[tuple[int, int, str]] = []
257
+
258
+ def walk(text: str, base: int, depth: int) -> None:
259
+ for m in CONCAT.finditer(text):
260
+ frag = m.group(0)
261
+ if "+" in frag or "__S" in frag or "." in frag:
262
+ out.append((base + m.start(), base + m.end(), frag))
263
+ if depth < 4:
264
+ for a in re.finditer(r"\(", frag):
265
+ close = match_brace(frag, a.start())
266
+ inner = frag[a.start() + 1:close]
267
+ if "+" in inner or "__S" in inner:
268
+ walk(inner, base + m.start() + a.start() + 1, depth + 1)
269
+
270
+ walk(scan_.code, 0, 0)
271
+ return out
272
+
273
+
274
+ def enclosing_call(scan_: Scan, offset: int, callee_pattern: str) -> Optional[tuple[int, int, str]]:
275
+ """Innermost call like `$.ajax(` whose parentheses contain offset -> (start, end, code of args)."""
276
+ best = None
277
+ for m in re.finditer(callee_pattern + r"\s*\(", scan_.code[:offset]):
278
+ open_pos = m.end() - 1
279
+ close = match_brace(scan_.code, open_pos)
280
+ if open_pos < offset <= close:
281
+ best = (open_pos, close, scan_.code[open_pos + 1:close])
282
+ return best
283
+
284
+
285
+ def object_props(scan_: Scan, obj_code: str) -> dict[str, str]:
286
+ """Top-level `key: value` pairs of an object literal (values returned as restored source)."""
287
+ body = obj_code.strip()
288
+ if body.startswith("{"):
289
+ body = body[1:body.rfind("}")]
290
+ props, depth, cur = {}, 0, []
291
+ chunks = []
292
+ for ch in body:
293
+ if ch in "([{":
294
+ depth += 1
295
+ elif ch in ")]}":
296
+ depth -= 1
297
+ if ch == "," and depth == 0:
298
+ chunks.append("".join(cur))
299
+ cur = []
300
+ else:
301
+ cur.append(ch)
302
+ chunks.append("".join(cur))
303
+ for c in chunks:
304
+ m = re.match(r"\s*(__S\d+__|[\w$]+)\s*:\s*(.*)$", c, re.S)
305
+ if not m:
306
+ continue
307
+ key = m.group(1)
308
+ lit = scan_.lit(key)
309
+ props[lit.value if lit else key] = m.group(2).strip()
310
+ return props
311
+
312
+
313
+ def if_condition(scan_: Scan, offset: int) -> Optional[str]:
314
+ """Condition of the innermost `if (...) {` block containing offset, as restored source."""
315
+ best = None
316
+ for m in re.finditer(r"\bif\s*\(", scan_.code[:offset]):
317
+ open_p = m.end() - 1
318
+ close_p = match_brace(scan_.code, open_p)
319
+ rest = scan_.code[close_p + 1:]
320
+ brace = re.match(r"\s*\{", rest)
321
+ if brace:
322
+ b = close_p + 1 + brace.end() - 1
323
+ end = match_brace(scan_.code, b)
324
+ if b < offset <= end:
325
+ best = scan_.code[open_p + 1:close_p]
326
+ else: # single statement if
327
+ stmt_end = scan_.code.find(";", close_p)
328
+ if close_p < offset <= stmt_end:
329
+ best = scan_.code[open_p + 1:close_p]
330
+ return re.sub(r"\s+", " ", scan_.restore(best)).strip() if best else None