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,374 @@
1
+ """The concrete agents: what each one is given, and how its output is checked before it is accepted."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import py_compile
6
+ import re
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ from pydantic import ValidationError
13
+
14
+ from ..analysis.aspx import load_page
15
+ from ..analysis.inventory import Legacy
16
+ from ..spec.coverage import check
17
+ from ..spec.model import Spec
18
+ from .core import Agent, AgentResult
19
+
20
+ MAX_SOURCE_LINES = 3500
21
+ ALLOWED_PAGE_IMPORTS = {
22
+ "react", "react-router-dom", "../../api", "../../runtime/user", "../../runtime/format", "../../runtime/spec",
23
+ "../../runtime/rules", "../../components/sp/Layout", "../../components/sp/DataTable", "../../components/sp/Tiles",
24
+ "../../components/sp/ListView", "../../components/sp/LegacyHtml", "../../components/sp/Banner",
25
+ }
26
+ FORBIDDEN_CODE = [(r"dangerouslySetInnerHTML", "use <LegacyHtml html=...> instead of dangerouslySetInnerHTML"),
27
+ (r"\bfetch\s*\(", "use the api client instead of fetch()"), (r"XMLHttpRequest", "use the api client"),
28
+ (r"\beval\s*\(", "eval is not allowed"), (r"(?<![\w.])\$\(", "jQuery is not available in the new app"),
29
+ (r"_spPageContextInfo", "use useUser() instead of _spPageContextInfo")]
30
+
31
+
32
+ # ----------------------------------------------------------------- helpers
33
+ def numbered(text: str, start: int = 1, only: Optional[set[int]] = None) -> str:
34
+ out, last = [], None
35
+ for i, line in enumerate(text.splitlines(), start):
36
+ if only is not None and i not in only:
37
+ continue
38
+ if only is not None and last is not None and i != last + 1:
39
+ out.append(" ...")
40
+ out.append(f"{i:5d}| {line}")
41
+ last = i
42
+ return "\n".join(out)
43
+
44
+
45
+ def script_sources(legacy: Legacy, inv: dict, budget: int = MAX_SOURCE_LINES) -> str:
46
+ texts: list[tuple[str, str]] = []
47
+ for s in inv["scripts"]["custom"]:
48
+ name = s["path"]
49
+ if "#inline" in name:
50
+ page, n = name.split("#inline")
51
+ parsed = load_page(legacy.root, page)
52
+ code = parsed.inline_scripts[int(n) - 1]["code"] if int(n) - 1 < len(parsed.inline_scripts) else ""
53
+ else:
54
+ code = legacy.file_text(name) or ""
55
+ texts.append((name, code))
56
+ total = sum(t.count("\n") + 1 for _, t in texts)
57
+ lines_by_file: dict[str, set[int]] = {}
58
+ if total > budget: # keep windows around the behaviours only
59
+ for b in inv["behaviors"]:
60
+ if b.get("line"):
61
+ lines_by_file.setdefault(b["file"], set()).update(range(max(1, b["line"] - 12), b["line"] + 13))
62
+ parts = []
63
+ for name, code in texts:
64
+ only = lines_by_file.get(name) if total > budget else None
65
+ modes = next((s.get("modes") for s in inv["scripts"]["custom"] if s["path"] == name), None)
66
+ parts.append(f'<source file="{name}" loaded_on="{",".join(modes or []) or "page"}">\n{numbered(code, only=only)}\n</source>')
67
+ return "\n".join(parts)
68
+
69
+
70
+ def compact_spec(spec: Spec) -> str:
71
+ d = json.loads(spec.model_dump_json(exclude_none=True))
72
+ for l in d["lists"].values():
73
+ l["fields"] = [{k: f[k] for k in ("name", "label", "type", "required", "choices", "lookup", "default", "multi", "max_length", "read_only") if k in f}
74
+ for f in l["fields"]]
75
+ l.pop("views", None)
76
+ for c in d["screen"]["components"]:
77
+ if "legacy_html" in c.get("props", {}):
78
+ c["props"]["legacy_html"] = c["props"]["legacy_html"][:600]
79
+ return json.dumps(d, indent=1)
80
+
81
+
82
+ def compact_behaviors(inv: dict) -> str:
83
+ rows = []
84
+ for b in inv["behaviors"]:
85
+ r = {"id": b["id"], "level": b["level"], "kind": b["kind"], "code": b.get("code")}
86
+ for k in ("function", "message", "condition"):
87
+ if b.get(k):
88
+ r[k] = b[k]
89
+ if b.get("presave"):
90
+ r["in"] = "PreSaveAction"
91
+ if b.get("fields"):
92
+ r["fields"] = [f["name"] if f["list"] == inv["screen"].get("list") else f"{f['list']}.{f['name']}" for f in b["fields"]]
93
+ det = {k: v for k, v in (b.get("detail") or {}).items() if k in ("method", "url", "group", "messages", "presentation", "mode", "event",
94
+ "operation", "spservices", "listName", "CAMLQuery", "relationshipList",
95
+ "relationshipListParentColumn", "parentColumn", "childColumn", "ported", "list", "view_xml", "param")}
96
+ if det:
97
+ r["detail"] = det
98
+ rows.append(json.dumps(r, ensure_ascii=False))
99
+ return "\n".join(rows)
100
+
101
+
102
+ def merge_patch(base: Spec, patch: dict) -> Spec:
103
+ d = json.loads(base.model_dump_json())
104
+ base_ids = {r["id"] for r in d["rules"]}
105
+ for r in patch.get("rules", []):
106
+ if r.get("id") in base_ids:
107
+ raise ValueError(f"rule id {r.get('id')} collides with a base rule; use R1, R2, ...")
108
+ d["rules"] = d["rules"] + list(patch.get("rules", []))
109
+ for key, target in (("components", d["screen"]["components"]), ("endpoints", d["endpoints"])):
110
+ for item in patch.get(key, []):
111
+ idx = next((i for i, x in enumerate(target) if x["id"] == item.get("id")), None)
112
+ if idx is None:
113
+ target.append(item)
114
+ else:
115
+ target[idx] = item
116
+ d["not_carried"] = d.get("not_carried", []) + list(patch.get("not_carried", []))
117
+ d["open_questions"] = d.get("open_questions", []) + list(patch.get("open_questions", []))
118
+ return Spec.model_validate(d)
119
+
120
+
121
+ def _pydantic_problems(e: ValidationError) -> list[str]:
122
+ return [f"{'.'.join(str(x) for x in err['loc'])}: {err['msg']}" for err in e.errors()[:40]]
123
+
124
+
125
+ def literal_chunks(message: str) -> list[str]:
126
+ return [c.strip() for c in re.split(r"\{[^{}]*\}", message) if len(c.strip()) >= 8]
127
+
128
+
129
+ # -------------------------------------------------------------- spec agent
130
+ def run_spec_agent(agent: Agent, legacy: Legacy, inv: dict, base: Spec) -> tuple[Spec, AgentResult]:
131
+ holder: dict[str, Spec] = {}
132
+
133
+ def validate(patch: dict) -> list[str]:
134
+ try:
135
+ spec = merge_patch(base, patch)
136
+ except ValidationError as e:
137
+ return _pydantic_problems(e)
138
+ except (ValueError, KeyError, TypeError) as e:
139
+ return [str(e)]
140
+ rep = check(spec, inv)
141
+ if rep.ok:
142
+ holder["spec"] = spec
143
+ return rep.problems
144
+
145
+ sections = {
146
+ "screen_summary": f"Screen {inv['screen']['id']} ({inv['screen']['kind']}), pages: {', '.join(inv['screen']['pages'])}. "
147
+ f"Primary list: {inv['screen'].get('list') or 'none'}. Site: {inv['site'].get('title')}.",
148
+ "base_spec": compact_spec(base),
149
+ "behaviors": compact_behaviors(inv),
150
+ "sources": script_sources(legacy, inv),
151
+ "html_fragments": "\n".join(f'<fragment page="{f["page"]}" mode="{f.get("mode") or ""}" web_part="{f.get("web_part", "")}">\n{f["html"][:3000]}\n</fragment>'
152
+ for f in inv.get("html_fragments", [])),
153
+ "unresolved": "\n".join(f"- {u['kind']}: {u['detail']}" for u in inv.get("unresolved", [])),
154
+ }
155
+ res = agent.run(inv["screen"]["id"], sections, validate)
156
+ return holder["spec"], res
157
+
158
+
159
+ # -------------------------------------------------------------- page agent
160
+ def ts_problems(out: Path, files: dict[str, str], timeout: int = 120) -> list[str]:
161
+ """Type-check candidate files inside the generated frontend when its node_modules exist."""
162
+ fe = out / "frontend"
163
+ tsc = fe / "node_modules" / ".bin" / "tsc"
164
+ if not tsc.exists():
165
+ return []
166
+ backups = {}
167
+ try:
168
+ for rel, content in files.items():
169
+ p = out / rel
170
+ backups[p] = p.read_text(encoding="utf-8") if p.exists() else None
171
+ p.parent.mkdir(parents=True, exist_ok=True)
172
+ p.write_text(content, encoding="utf-8")
173
+ r = subprocess.run([str(tsc), "--noEmit", "-p", str(fe)], cwd=fe, capture_output=True, text=True, timeout=timeout)
174
+ errs = []
175
+ for line in r.stdout.splitlines():
176
+ m = re.match(r"(src/[^(]+)\((\d+),(\d+)\): error (TS\d+): (.*)", line)
177
+ if m and f"frontend/{m.group(1)}" in files:
178
+ errs.append(f"{m.group(1)}:{m.group(2)}:{m.group(3)} {m.group(4)} {m.group(5)}")
179
+ return errs[:30]
180
+ finally:
181
+ for p, old in backups.items():
182
+ if old is None:
183
+ p.unlink(missing_ok=True)
184
+ else:
185
+ p.write_text(old, encoding="utf-8")
186
+
187
+
188
+ def page_file_problems(files: dict[str, str], prefix: str, must_texts: list[str]) -> list[str]:
189
+ problems = []
190
+ if not any(p == f"{prefix}Page.tsx" for p in files):
191
+ problems.append(f"Missing {prefix}Page.tsx")
192
+ for path, code in files.items():
193
+ if not path.startswith(prefix) or not path.endswith((".tsx", ".ts")):
194
+ problems.append(f"{path}: files must be .ts/.tsx under {prefix}")
195
+ continue
196
+ for mod in re.findall(r"""from\s+["']([^"']+)["']""", code):
197
+ if mod not in ALLOWED_PAGE_IMPORTS and not mod.startswith("./"):
198
+ problems.append(f"{path}: import '{mod}' is not allowed (allowed: {sorted(ALLOWED_PAGE_IMPORTS)} or ./local files)")
199
+ for pat, why in FORBIDDEN_CODE:
200
+ if re.search(pat, code):
201
+ problems.append(f"{path}: {why}")
202
+ if code.count("{") != code.count("}") or code.count("(") != code.count(")"):
203
+ problems.append(f"{path}: unbalanced braces/parentheses (file looks truncated)")
204
+ page = files.get(f"{prefix}Page.tsx", "")
205
+ if page and not re.search(r"export\s+default\s+function\s+Page\s*\(", page):
206
+ problems.append(f"{prefix}Page.tsx must `export default function Page({{ spec }}: {{ spec: Spec }})`")
207
+ allcode = "\n".join(files.values())
208
+ for msg in must_texts:
209
+ for chunk in literal_chunks(msg):
210
+ if chunk not in allcode:
211
+ problems.append(f"Legacy text missing or reworded: \"{chunk}\" (from \"{msg}\")")
212
+ return problems
213
+
214
+
215
+ def run_page_agent(agent: Agent, legacy: Legacy, inv: dict, spec: Spec, routes: list[dict], out: Path) -> AgentResult:
216
+ sid = spec.screen.id
217
+ prefix = f"frontend/src/screens/{sid}/"
218
+ dropped = {c for n in spec.not_carried for c in n.covers}
219
+ must_texts = []
220
+ for b in inv["behaviors"]:
221
+ if b["level"] == "must" and b["id"] not in dropped:
222
+ if b.get("message"):
223
+ must_texts.append(b["message"])
224
+ must_texts += (b.get("detail") or {}).get("messages", [])
225
+
226
+ def validate(parsed: dict) -> list[str]:
227
+ files = parsed["files"]
228
+ probs = page_file_problems(files, prefix, must_texts)
229
+ return probs or ts_problems(out, files)
230
+
231
+ css = "\n".join(f"/* {rel} */\n{legacy.file_text(rel) or ''}" for rel in spec.screen.stylesheets)
232
+ existing = out / prefix / "Page.tsx"
233
+ sections = {
234
+ "spec": compact_spec(spec),
235
+ "routes": json.dumps(routes, indent=1),
236
+ "legacy_html": "\n".join(f'<fragment web_part="{f.get("web_part", "")}">\n{f["html"]}\n</fragment>' for f in inv.get("html_fragments", [])),
237
+ "legacy_css": css[:12000],
238
+ "sources": script_sources(legacy, inv),
239
+ "existing_file": existing.read_text(encoding="utf-8") if existing.exists() else "",
240
+ "task": f"Write {prefix}Page.tsx for screen {sid} ('{spec.screen.title}').",
241
+ }
242
+ res = agent.run(sid, sections, validate)
243
+ for rel, content in res.output["files"].items():
244
+ p = out / rel
245
+ p.parent.mkdir(parents=True, exist_ok=True)
246
+ p.write_text(content, encoding="utf-8")
247
+ return res
248
+
249
+
250
+ # ----------------------------------------------------------- handler agent
251
+ def run_handler_agent(agent: Agent, legacy: Legacy, inv: dict, spec: Spec, rule: Any, out: Path) -> AgentResult:
252
+ handler = rule.params["handler"]
253
+ allowed = {f"frontend/src/custom/{handler}.ts", f"backend/app/custom/{handler}.py"}
254
+
255
+ def validate(parsed: dict) -> list[str]:
256
+ probs = []
257
+ for path, code in parsed["files"].items():
258
+ if path not in allowed:
259
+ probs.append(f"{path}: only {sorted(allowed)} may be written")
260
+ elif path.endswith(".py"):
261
+ with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as t:
262
+ t.write(code)
263
+ try:
264
+ py_compile.compile(t.name, doraise=True)
265
+ except py_compile.PyCompileError as e:
266
+ probs.append(f"{path}: {e.msg}")
267
+ else:
268
+ for mod in re.findall(r"""from\s+["']([^"']+)["']""", code):
269
+ if mod not in ("../runtime/rules", "../api", "../runtime/spec"):
270
+ probs.append(f"{path}: import '{mod}' is not allowed")
271
+ return probs or ts_problems(out, {p: c for p, c in parsed["files"].items() if p.endswith(".ts")})
272
+
273
+ sections = {"rule": rule.model_dump_json(indent=1, exclude_none=True), "spec": compact_spec(spec),
274
+ "behaviors": compact_behaviors({**inv, "behaviors": [b for b in inv["behaviors"] if b["id"] in rule.covers]}),
275
+ "sources": script_sources(legacy, inv)}
276
+ res = agent.run(f"{spec.screen.id}-{handler}", sections, validate)
277
+ for rel, content in res.output["files"].items():
278
+ (out / rel).parent.mkdir(parents=True, exist_ok=True)
279
+ (out / rel).write_text(content, encoding="utf-8")
280
+ return res
281
+
282
+
283
+ # ------------------------------------------------------------ review agent
284
+ def run_review_agent(agent: Agent, legacy: Legacy, inv: dict, spec: Spec, out: Path) -> AgentResult:
285
+ def validate(r: dict) -> list[str]:
286
+ probs = []
287
+ if r.get("verdict") not in ("pass", "fail"):
288
+ probs.append("verdict must be 'pass' or 'fail'")
289
+ if not isinstance(r.get("findings"), list):
290
+ probs.append("findings must be a list")
291
+ else:
292
+ for i, f in enumerate(r["findings"]):
293
+ if f.get("severity") not in ("blocker", "major", "minor"):
294
+ probs.append(f"findings[{i}].severity must be blocker|major|minor")
295
+ if not f.get("problem"):
296
+ probs.append(f"findings[{i}] needs a problem description")
297
+ if r.get("verdict") == "pass" and any(f.get("severity") in ("blocker", "major") for f in r["findings"]):
298
+ probs.append("verdict cannot be 'pass' with blocker/major findings")
299
+ return probs
300
+
301
+ code = []
302
+ sid = spec.screen.id
303
+ for p in sorted((out / "frontend" / "src" / "screens" / sid).glob("*.ts*")) if (out / "frontend" / "src" / "screens" / sid).exists() else []:
304
+ code.append(f'<file path="{p.relative_to(out).as_posix()}">\n{p.read_text(encoding="utf-8")}\n</file>')
305
+ for r in spec.rules:
306
+ if r.kind == "custom":
307
+ for rel in (f"frontend/src/custom/{r.params['handler']}.ts", f"backend/app/custom/{r.params['handler']}.py"):
308
+ if (out / rel).exists():
309
+ code.append(f'<file path="{rel}">\n{(out / rel).read_text(encoding="utf-8")}\n</file>')
310
+ sections = {"behaviors": compact_behaviors(inv), "spec": spec.model_dump_json(indent=1, exclude_none=True),
311
+ "sources": script_sources(legacy, inv), "generated_code": "\n".join(code),
312
+ "note": "List forms are rendered by the shared form engine directly from the spec rules; review the rules for them."}
313
+ return agent.run(sid, sections, validate)
314
+
315
+
316
+ # --------------------------------------------------------------- fix agent
317
+ def run_fix_agent(agent: Agent, inv: dict, spec: Spec, failures: str, out: Path, task_id: str) -> tuple[AgentResult, Optional[Spec]]:
318
+ sid = spec.screen.id
319
+ editable = {p.relative_to(out).as_posix() for p in (out / "frontend" / "src" / "screens" / sid).glob("*.ts*")} if (out / "frontend" / "src" / "screens" / sid).exists() else set()
320
+ for r in spec.rules:
321
+ if r.kind == "custom":
322
+ editable |= {f"frontend/src/custom/{r.params['handler']}.ts", f"backend/app/custom/{r.params['handler']}.py"}
323
+ holder: dict = {}
324
+
325
+ def validate(parsed: dict) -> list[str]:
326
+ probs = [f"{p}: not an editable file (editable: {sorted(editable)})" for p in parsed["files"] if p not in editable]
327
+ if parsed.get("spec_patch"):
328
+ patch = parsed["spec_patch"]
329
+ d = json.loads(spec.model_dump_json())
330
+ by_id = {r["id"]: i for i, r in enumerate(d["rules"])}
331
+ for r in patch.get("rules", []):
332
+ if r.get("id") in by_id:
333
+ d["rules"][by_id[r["id"]]] = r
334
+ else:
335
+ d["rules"].append(r)
336
+ try:
337
+ new = Spec.model_validate(d)
338
+ except ValidationError as e:
339
+ return probs + _pydantic_problems(e)
340
+ rep = check(new, inv)
341
+ probs += rep.problems
342
+ holder["spec"] = new
343
+ if not probs and parsed["files"]:
344
+ probs += ts_problems(out, {p: c for p, c in parsed["files"].items() if p.endswith((".ts", ".tsx"))})
345
+ return probs
346
+
347
+ files = "\n".join(f'<file path="{p}">\n{(out / p).read_text(encoding="utf-8")}\n</file>' for p in sorted(editable) if (out / p).exists())
348
+ sections = {"failures": failures[-12000:], "files": files, "spec": compact_spec(spec)}
349
+ res = agent.run(task_id, sections, validate)
350
+ for rel, content in res.output["files"].items():
351
+ (out / rel).write_text(content, encoding="utf-8")
352
+ return res, holder.get("spec")
353
+
354
+
355
+ # ------------------------------------------------------------- dbmap agent
356
+ def run_dbmap_agent(agent: Agent, lists: dict, db: dict, draft_mapping: dict, notes: list[str]) -> tuple[dict, list[str], AgentResult]:
357
+ from ..dbmap import validate as validate_mapping
358
+ holder: dict = {}
359
+
360
+ def validate(out: dict) -> list[str]:
361
+ m = out.get("mapping")
362
+ if not isinstance(m, dict) or "lists" not in m:
363
+ return ['Return {"mapping": {...}, "notes": [...]} with mapping.lists']
364
+ problems, _ = validate_mapping(m, lists, db)
365
+ if not problems:
366
+ holder["mapping"] = m
367
+ return problems
368
+
369
+ compact_lists = {k: {"title": v["title"], "fields": [{x: f.get(x) for x in ("name", "label", "type", "required", "multi", "lookup") if f.get(x) is not None}
370
+ for f in v["fields"]]} for k, v in lists.items()}
371
+ sections = {"sharepoint_lists": json.dumps(compact_lists, indent=1), "database_schema": json.dumps(db["tables"], indent=1),
372
+ "draft_mapping": json.dumps(draft_mapping, indent=1), "unresolved": "\n".join(f"- {n}" for n in notes)}
373
+ res = agent.run("db-mapping", sections, validate)
374
+ return holder["mapping"], list(res.output.get("notes") or []), res
File without changes
@@ -0,0 +1,254 @@
1
+ """Parse classic SharePoint .aspx pages and web part definitions.
2
+
3
+ Handles the three places page behaviour lives:
4
+ * web parts embedded in the .aspx (SharePoint Designer / site definition forms),
5
+ * web parts added in the browser (exported via the web part manager -> *.webparts.json),
6
+ * script/link tags in the page itself and inside web part HTML.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import html
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+
17
+ TAG_VALUE = r"<(?:\w+:)?{tag}\b[^>]*>(.*?)</(?:\w+:)?{tag}>"
18
+
19
+
20
+ def _tag(xml: str, tag: str) -> Optional[str]:
21
+ m = re.search(TAG_VALUE.format(tag=tag), xml, re.S | re.I)
22
+ if not m:
23
+ return None
24
+ val = m.group(1).strip()
25
+ if val.startswith("<![CDATA["):
26
+ val = val[9:-3]
27
+ return html.unescape(val)
28
+
29
+
30
+ def _attrs(tag_text: str) -> dict[str, str]:
31
+ out = {}
32
+ for m in re.finditer(r'([\w:\-]+)\s*=\s*"([^"]*)"', tag_text, re.S):
33
+ out[m.group(1)] = html.unescape(m.group(2))
34
+ return out
35
+
36
+
37
+ @dataclass
38
+ class WebPart:
39
+ id: str
40
+ type: str # ListForm | ScriptEditor | ContentEditor | ListView | Other:<name>
41
+ title: str
42
+ zone: Optional[str] = None
43
+ order: int = 0
44
+ source: str = "aspx" # aspx | webpartmanager
45
+ control_mode: Optional[str] = None # New | Edit | Display (ListForm)
46
+ list_id: Optional[str] = None
47
+ list_url: Optional[str] = None
48
+ content: Optional[str] = None # inline HTML/script
49
+ content_link: Optional[str] = None # CEWP link to an .html file
50
+ jslink: Optional[str] = None
51
+ view_xml: Optional[str] = None
52
+ hidden: bool = False
53
+
54
+ def to_dict(self) -> dict:
55
+ return {k: v for k, v in self.__dict__.items() if v not in (None, "", False) or k in ("id", "type", "title")}
56
+
57
+
58
+ @dataclass
59
+ class ParsedPage:
60
+ path: str
61
+ master: Optional[str] = None
62
+ title: Optional[str] = None
63
+ web_parts: list[WebPart] = field(default_factory=list)
64
+ stylesheets: list[str] = field(default_factory=list)
65
+ scripts: list[str] = field(default_factory=list) # src URLs in page order
66
+ inline_scripts: list[dict] = field(default_factory=list) # {where, code}
67
+ html_fragments: list[dict] = field(default_factory=list) # {web_part, html} non-script markup authored in web parts
68
+
69
+ @property
70
+ def form_mode(self) -> Optional[str]:
71
+ for wp in self.web_parts:
72
+ if wp.type == "ListForm" and wp.control_mode:
73
+ return wp.control_mode
74
+ return None
75
+
76
+ @property
77
+ def list_id(self) -> Optional[str]:
78
+ for wp in self.web_parts:
79
+ if wp.type in ("ListForm",) and wp.list_id:
80
+ return wp.list_id
81
+ return None
82
+
83
+ def to_dict(self) -> dict:
84
+ return {
85
+ "path": self.path, "master": self.master, "title": self.title, "form_mode": self.form_mode,
86
+ "list_id": self.list_id, "web_parts": [w.to_dict() for w in self.web_parts],
87
+ "stylesheets": self.stylesheets, "scripts": self.scripts,
88
+ "inline_scripts": [{"where": s["where"], "lines": s["code"].count("\n") + 1} for s in self.inline_scripts],
89
+ }
90
+
91
+
92
+ # ------------------------------------------------------------------ markup
93
+ SCRIPT_SRC = re.compile(r"<script\b[^>]*\bsrc\s*=\s*[\"']([^\"']+)[\"'][^>]*>", re.I)
94
+ SCRIPT_INLINE = re.compile(r"<script\b(?![^>]*\bsrc\s*=)[^>]*>(.*?)</script>", re.I | re.S)
95
+ LINK_CSS = re.compile(r"<link\b[^>]*\bhref\s*=\s*[\"']([^\"']+\.css[^\"']*)[\"'][^>]*>", re.I)
96
+ CSS_REG = re.compile(r"<SharePoint:CssRegistration\b[^>]*\bName\s*=\s*\"([^\"]+)\"", re.I)
97
+ SCRIPT_LINK = re.compile(r"<SharePoint:ScriptLink\b[^>]*\bName\s*=\s*\"([^\"]+)\"", re.I)
98
+
99
+
100
+ def scan_html(markup: str) -> tuple[list[str], list[str], list[str], str]:
101
+ """-> (script srcs, inline scripts, stylesheets, markup without scripts)."""
102
+ srcs = [html.unescape(s) for s in SCRIPT_SRC.findall(markup)]
103
+ inline = [c for c in SCRIPT_INLINE.findall(markup) if c.strip()]
104
+ css = [html.unescape(s) for s in LINK_CSS.findall(markup)] + CSS_REG.findall(markup)
105
+ stripped = re.sub(r"<script\b.*?</script>", "", markup, flags=re.I | re.S).strip()
106
+ return srcs, inline, css, stripped
107
+
108
+
109
+ # ---------------------------------------------------------- web part parsing
110
+ WP_BLOCK = re.compile(r"<WebPartPages:(\w+)\b([^>]*?)(/>|>(.*?)</WebPartPages:\1>)", re.S | re.I)
111
+
112
+
113
+ def _wp_from_markup(kind: str, attr_text: str, body: str, order: int) -> WebPart:
114
+ attrs = _attrs(attr_text)
115
+ body = body or ""
116
+ wid = attrs.get("__WebPartId") or _tag(body, "ID") or attrs.get("ID") or f"wp{order}"
117
+ title = attrs.get("Title") or _tag(body, "Title") or kind
118
+ k = kind.lower()
119
+ if k == "listformwebpart":
120
+ return WebPart(id=wid, type="ListForm", title=title, order=order, control_mode=_tag(body, "ControlMode"),
121
+ list_id=(_tag(body, "ListName") or _tag(body, "ListId") or "").strip("{}").lower() or None,
122
+ jslink=_tag(body, "JSLink"), hidden=(_tag(body, "IsVisible") or "true").lower() == "false")
123
+ if k == "scripteditorwebpart":
124
+ return WebPart(id=wid, type="ScriptEditor", title=title, order=order, content=attrs.get("Content") or _tag(body, "Content"))
125
+ if k == "contenteditorwebpart":
126
+ return WebPart(id=wid, type="ContentEditor", title=title, order=order,
127
+ content=_tag(body, "Content") or attrs.get("Content"),
128
+ content_link=_tag(body, "ContentLink") or attrs.get("ContentLink"))
129
+ if k in ("xsltlistviewwebpart", "listviewwebpart"):
130
+ return WebPart(id=wid, type="ListView", title=title, order=order,
131
+ list_id=(attrs.get("ListName") or attrs.get("ListId") or _tag(body, "ListName") or "").strip("{}").lower() or None,
132
+ list_url=attrs.get("ListUrl") or _tag(body, "ListUrl"),
133
+ view_xml=attrs.get("XmlDefinition") or _tag(body, "XmlDefinition"),
134
+ jslink=attrs.get("JSLink") or _tag(body, "JSLink"))
135
+ if k in ("webpartzone",):
136
+ raise ValueError("zone")
137
+ return WebPart(id=wid, type=f"Other:{kind}", title=title, order=order, content=attrs.get("Content"))
138
+
139
+
140
+ def web_parts_from_aspx(source: str) -> list[WebPart]:
141
+ out: list[WebPart] = []
142
+ # zones contain web parts; iterate zones to record the zone id
143
+ zones = list(re.finditer(r"<WebPartPages:WebPartZone\b([^>]*)>(.*?)</WebPartPages:WebPartZone>", source, re.S | re.I))
144
+ regions = [(_attrs(z.group(1)).get("ID"), z.group(2)) for z in zones] or [(None, source)]
145
+ order = 0
146
+ for zone_id, region in regions:
147
+ for m in WP_BLOCK.finditer(region):
148
+ if m.group(1).lower() == "webpartzone":
149
+ continue
150
+ order += 1
151
+ try:
152
+ wp = _wp_from_markup(m.group(1), m.group(2), m.group(4) or "", order)
153
+ except ValueError:
154
+ continue
155
+ wp.zone = zone_id
156
+ out.append(wp)
157
+ return out
158
+
159
+
160
+ def web_parts_from_manager(items: list[dict]) -> list[WebPart]:
161
+ """Classify REST web part manager output (properties only; SharePoint gives no type name)."""
162
+ out = []
163
+ for i, d in enumerate(items):
164
+ wpd = d.get("WebPart") or {}
165
+ props = wpd.get("Properties") or {}
166
+ if isinstance(props, dict) and "results" in props: # some versions wrap it
167
+ props = {p.get("Key"): p.get("Value") for p in props["results"]}
168
+ title = props.get("Title") or wpd.get("Title") or f"Web part {i + 1}"
169
+ common = dict(id=str(d.get("Id")), title=title, zone=d.get("ZoneId"), order=wpd.get("ZoneIndex", i),
170
+ source="webpartmanager", hidden=bool(wpd.get("Hidden") or wpd.get("IsClosed")))
171
+ if props.get("XmlDefinition") or props.get("ListUrl") or props.get("ViewGuid"):
172
+ wp = WebPart(type="ListView", list_id=(props.get("ListName") or props.get("ListId") or "").strip("{}").lower() or None,
173
+ list_url=props.get("ListUrl"), view_xml=props.get("XmlDefinition"), jslink=props.get("JSLink") or None, **common)
174
+ elif props.get("ContentLink"):
175
+ wp = WebPart(type="ContentEditor", content_link=props.get("ContentLink"), content=props.get("Content"), **common)
176
+ elif props.get("ControlMode") or props.get("FormType"):
177
+ wp = WebPart(type="ListForm", control_mode=props.get("ControlMode"),
178
+ list_id=(props.get("ListName") or props.get("ListId") or "").strip("{}").lower() or None,
179
+ jslink=props.get("JSLink") or None, **common)
180
+ elif props.get("Content") is not None:
181
+ # Script Editor and inline Content Editor are indistinguishable over REST; both are "authored HTML"
182
+ wp = WebPart(type="ScriptEditor", content=props.get("Content"), **common)
183
+ else:
184
+ wp = WebPart(type="Other", **common)
185
+ out.append(wp)
186
+ return out
187
+
188
+
189
+ def parse_page(path: str, source: str, webparts_json: Optional[list[dict]] = None) -> ParsedPage:
190
+ page = ParsedPage(path=path)
191
+ m = re.search(r'MasterPageFile\s*=\s*"([^"]+)"', source)
192
+ page.master = m.group(1) if m else None
193
+ t = re.search(r'ContentPlaceHolderId="PlaceHolderPageTitle"[^>]*>(.*?)</asp:Content>', source, re.S)
194
+ if t:
195
+ page.title = re.sub(r"<[^>]+>", "", t.group(1)).strip() or None
196
+
197
+ wps = web_parts_from_aspx(source)
198
+ if webparts_json:
199
+ seen = {w.id.strip("{}").lower() for w in wps}
200
+ for w in web_parts_from_manager(webparts_json):
201
+ if w.id.strip("{}").lower() not in seen:
202
+ wps.append(w)
203
+ page.web_parts = wps
204
+
205
+ # page-level tags (outside web parts)
206
+ outside = WP_BLOCK.sub("", source)
207
+ srcs, inline, css, _ = scan_html(outside)
208
+ page.scripts += srcs + SCRIPT_LINK.findall(outside)
209
+ page.stylesheets += css
210
+ page.inline_scripts += [{"where": "page", "code": c} for c in inline]
211
+
212
+ for w in wps:
213
+ if w.jslink:
214
+ page.scripts += [j.strip() for j in w.jslink.split("|") if j.strip()]
215
+ if w.content:
216
+ srcs, inline, css, stripped = scan_html(w.content)
217
+ page.scripts += srcs
218
+ page.stylesheets += css
219
+ page.inline_scripts += [{"where": f"webpart:{w.title}", "code": c} for c in inline]
220
+ if stripped:
221
+ page.html_fragments.append({"web_part": w.title, "html": stripped})
222
+ return page
223
+
224
+
225
+ def asset_refs(page: ParsedPage) -> list[str]:
226
+ refs = list(page.scripts) + list(page.stylesheets)
227
+ refs += [w.content_link for w in page.web_parts if w.content_link]
228
+ seen, out = set(), []
229
+ for r in refs:
230
+ r = resolve_tokens(r)
231
+ if r and r not in seen:
232
+ seen.add(r)
233
+ out.append(r)
234
+ return out
235
+
236
+
237
+ def resolve_tokens(url: str, site_rel: str = "") -> str:
238
+ """Expand ~site/~sitecollection tokens used in JSLink/ScriptLink."""
239
+ url = url.strip()
240
+ for tok in ("~sitecollection", "~site", "~layouts"):
241
+ if url.lower().startswith(tok):
242
+ rest = url[len(tok):]
243
+ return ("/_layouts/15" + rest) if tok == "~layouts" else (site_rel + rest)
244
+ return url
245
+
246
+
247
+ def load_page(legacy_dir: Path, rel_path: str) -> ParsedPage:
248
+ src = (legacy_dir / "pages" / rel_path).read_text(encoding="utf-8", errors="replace")
249
+ wpf = legacy_dir / "pages" / (rel_path + ".webparts.json")
250
+ wpj = None
251
+ if wpf.exists():
252
+ data = json.loads(wpf.read_text(encoding="utf-8"))
253
+ wpj = data.get("d", {}).get("results", data) if isinstance(data, dict) else data
254
+ return parse_page(rel_path, src, wpj)