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.
- sp2react/__init__.py +18 -0
- sp2react/agents/__init__.py +0 -0
- sp2react/agents/core.py +146 -0
- sp2react/agents/prompts/dbmap.md +28 -0
- sp2react/agents/prompts/fix.md +11 -0
- sp2react/agents/prompts/handler.md +22 -0
- sp2react/agents/prompts/page.md +40 -0
- sp2react/agents/prompts/review.md +21 -0
- sp2react/agents/prompts/spec.md +79 -0
- sp2react/agents/tasks.py +374 -0
- sp2react/analysis/__init__.py +0 -0
- sp2react/analysis/aspx.py +254 -0
- sp2react/analysis/behaviors.py +357 -0
- sp2react/analysis/fields.py +152 -0
- sp2react/analysis/inventory.py +314 -0
- sp2react/analysis/jsscan.py +330 -0
- sp2react/checks.py +105 -0
- sp2react/cli.py +188 -0
- sp2react/config.py +172 -0
- sp2react/dbmap.py +245 -0
- sp2react/llm/__init__.py +0 -0
- sp2react/llm/providers.py +219 -0
- sp2react/migrate.py +255 -0
- sp2react/net.py +60 -0
- sp2react/pipeline.py +583 -0
- sp2react/scaffold/__init__.py +0 -0
- sp2react/scaffold/generate.py +279 -0
- sp2react/scaffold/rule_vectors.json +3826 -0
- sp2react/scaffold/templates/backend/app/__init__.py +0 -0
- sp2react/scaffold/templates/backend/app/api.py +293 -0
- sp2react/scaffold/templates/backend/app/auth.py +33 -0
- sp2react/scaffold/templates/backend/app/custom/__init__.py +22 -0
- sp2react/scaffold/templates/backend/app/main.py +47 -0
- sp2react/scaffold/templates/backend/app/odata.py +185 -0
- sp2react/scaffold/templates/backend/app/rules.py +256 -0
- sp2react/scaffold/templates/backend/app/sp_repo.py +159 -0
- sp2react/scaffold/templates/backend/app/sql_repo.py +437 -0
- sp2react/scaffold/templates/backend/app/store.py +224 -0
- sp2react/scaffold/templates/backend/requirements.txt +8 -0
- sp2react/scaffold/templates/backend/tests/conftest.py +10 -0
- sp2react/scaffold/templates/backend/tests/test_rule_vectors.py +38 -0
- sp2react/scaffold/templates/frontend/index.html +12 -0
- sp2react/scaffold/templates/frontend/package.json +24 -0
- sp2react/scaffold/templates/frontend/scripts/check-rules.ts +23 -0
- sp2react/scaffold/templates/frontend/src/api.ts +88 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Banner.tsx +4 -0
- sp2react/scaffold/templates/frontend/src/components/sp/DataTable.tsx +38 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Form.tsx +252 -0
- sp2react/scaffold/templates/frontend/src/components/sp/GenericPage.tsx +28 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Layout.tsx +54 -0
- sp2react/scaffold/templates/frontend/src/components/sp/LegacyHtml.tsx +9 -0
- sp2react/scaffold/templates/frontend/src/components/sp/ListView.tsx +59 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Tiles.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/components/sp/fields.tsx +132 -0
- sp2react/scaffold/templates/frontend/src/custom/types.ts +10 -0
- sp2react/scaffold/templates/frontend/src/main.tsx +14 -0
- sp2react/scaffold/templates/frontend/src/runtime/format.ts +36 -0
- sp2react/scaffold/templates/frontend/src/runtime/rules.ts +225 -0
- sp2react/scaffold/templates/frontend/src/runtime/spec.ts +102 -0
- sp2react/scaffold/templates/frontend/src/runtime/user.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/screens/LegacyRedirect.tsx +27 -0
- sp2react/scaffold/templates/frontend/src/screens/ListFormScreen.tsx +33 -0
- sp2react/scaffold/templates/frontend/src/styles/sp.css +95 -0
- sp2react/scaffold/templates/frontend/src/vite-env.d.ts +1 -0
- sp2react/scaffold/templates/frontend/tsconfig.json +18 -0
- sp2react/scaffold/templates/frontend/vite.config.ts +7 -0
- sp2react/sharepoint/__init__.py +0 -0
- sp2react/sharepoint/client.py +210 -0
- sp2react/sharepoint/extract.py +217 -0
- sp2react/spec/__init__.py +0 -0
- sp2react/spec/base.py +80 -0
- sp2react/spec/coverage.py +191 -0
- sp2react/spec/model.py +133 -0
- sp2react-0.1.0.dist-info/METADATA +238 -0
- sp2react-0.1.0.dist-info/RECORD +79 -0
- sp2react-0.1.0.dist-info/WHEEL +5 -0
- sp2react-0.1.0.dist-info/entry_points.txt +2 -0
- sp2react-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|