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
sp2react/pipeline.py ADDED
@@ -0,0 +1,583 @@
1
+ """The ten stages, in order, with gating: a stage runs only when the stage before it finished.
2
+
3
+ 1 inputs config + connectivity (code)
4
+ 2 extract pull pages, web parts, scripts, lists (code, read-only)
5
+ 3 inventory behaviours + data calls per screen (code)
6
+ 4 spec base spec + spec agent + coverage gate (AI agent, verified)
7
+ 5 approve_spec human gate (you)
8
+ 6 convert scaffold + page/handler agents in parallel (code + AI agents, verified)
9
+ 7 review independent review agent (AI agent)
10
+ 8 test pytest, tsc/vite build, rule vectors (code)
11
+ 9 fix fix agent on failures, max N iterations (AI agent, verified)
12
+ 10 approve human gate (you)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import concurrent.futures as cf
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import threading
21
+ import time
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Callable, Optional
25
+
26
+ from .agents.core import Agent, AgentFailed
27
+ from .agents.tasks import run_fix_agent, run_handler_agent, run_page_agent, run_review_agent, run_spec_agent
28
+ from .analysis.inventory import Legacy, build_inventory, screens as find_screens
29
+ from .checks import failures_by_screen, run_all
30
+ from .config import Config
31
+ from .llm.providers import LLM, make_llm
32
+ from .net import policy_for
33
+ from .scaffold.generate import generate, refresh_registries
34
+ from .spec.base import base_spec
35
+ from .spec.coverage import check
36
+ from .spec.model import Spec
37
+
38
+ log = logging.getLogger(__name__)
39
+ STAGES = ["inputs", "extract", "inventory", "spec", "approve_spec", "convert", "review", "test", "fix", "approve"]
40
+
41
+
42
+ def _now() -> str:
43
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
44
+
45
+
46
+ class StageBlocked(RuntimeError):
47
+ pass
48
+
49
+
50
+ class Pipeline:
51
+ def __init__(self, cfg: Config, llm: Optional[LLM] = None, progress: Callable[[str], None] = print, sp_client=None):
52
+ self.cfg = cfg
53
+ self._print_lock = threading.Lock()
54
+ self._state_lock = threading.RLock() # stages run screens in parallel threads
55
+ self.ws = cfg.workspace
56
+ self.out = cfg.output
57
+ self.progress = lambda msg: self._say(progress, msg)
58
+ self._llm = llm
59
+ self._sp = sp_client
60
+ self.ws.mkdir(parents=True, exist_ok=True)
61
+ self.state_file = self.ws / "state.json"
62
+ self.state = json.loads(self.state_file.read_text()) if self.state_file.exists() else {"global": {}, "screens": {}, "usage": {}}
63
+
64
+ def _say(self, sink, msg: str) -> None:
65
+ with self._print_lock: # agents report from worker threads
66
+ sink(msg)
67
+
68
+ # ----------------------------------------------------------- plumbing
69
+ @property
70
+ def llm(self) -> LLM:
71
+ if self._llm is None:
72
+ self._llm = make_llm(self.cfg.llm, policy_for(self.cfg))
73
+ return self._llm
74
+
75
+ @property
76
+ def sp(self):
77
+ if self._sp is None:
78
+ from .sharepoint.client import SharePointClient
79
+ self._sp = SharePointClient(self.cfg.sharepoint, policy=policy_for(self.cfg), item_reads=self.cfg.item_reads_allowed)
80
+ return self._sp
81
+
82
+ @staticmethod
83
+ def _write_atomic(path: Path, text: str) -> None:
84
+ tmp = path.with_suffix(path.suffix + ".tmp")
85
+ tmp.write_text(text, encoding="utf-8")
86
+ tmp.replace(path)
87
+
88
+ def save(self) -> None:
89
+ with self._state_lock:
90
+ self._write_atomic(self.state_file, json.dumps(self.state, indent=2))
91
+
92
+ def mark(self, sid: Optional[str], stage: str, status: str, detail: str = "", **extra) -> None:
93
+ with self._state_lock:
94
+ bucket = self.state["global"] if sid is None else self.state["screens"].setdefault(sid, {})
95
+ bucket[stage] = {"status": status, "at": _now(), "detail": detail, **extra}
96
+ self.save()
97
+ self.progress(f"[{stage}] {sid or 'site'}: {status}{' - ' + detail if detail else ''}")
98
+
99
+ def status(self, sid: Optional[str], stage: str) -> Optional[str]:
100
+ bucket = self.state["global"] if sid is None else self.state["screens"].get(sid, {})
101
+ return (bucket.get(stage) or {}).get("status")
102
+
103
+ def require(self, sid: Optional[str], stage: str) -> None:
104
+ """Gate: the previous stage must be done (fixes 'review started while convert was still running')."""
105
+ idx = STAGES.index(stage)
106
+ prev = STAGES[idx - 1] if idx else None
107
+ if prev is None:
108
+ return
109
+ scope = None if prev in ("inputs", "extract") else sid
110
+ st = self.status(scope, prev)
111
+ if prev == "fix" and st in ("done", "skipped"):
112
+ return
113
+ if st != "done":
114
+ raise StageBlocked(f"{stage} for {sid or 'site'} needs '{prev}' to be done (it is {st or 'not started'})")
115
+
116
+ def agent(self, name: str, output: str) -> Agent:
117
+ return Agent(name, self.llm, self.ws, output=output, retries=self.cfg.pipeline.agent_retries, progress=self.progress)
118
+
119
+ def _usage(self, sid: str, stage: str, res) -> None:
120
+ with self._state_lock:
121
+ self._usage_locked(sid, stage, res)
122
+
123
+ def _usage_locked(self, sid: str, stage: str, res) -> None:
124
+ u = self.state["usage"].setdefault(sid, {})
125
+ cur = u.setdefault(stage, {"input_tokens": 0, "output_tokens": 0, "attempts": 0, "seconds": 0})
126
+ cur["input_tokens"] += res.input_tokens
127
+ cur["output_tokens"] += res.output_tokens
128
+ cur["attempts"] += res.attempts
129
+ cur["seconds"] += round(res.seconds, 1)
130
+ self.save()
131
+
132
+ @property
133
+ def legacy(self) -> Legacy:
134
+ return Legacy(self.ws / "legacy")
135
+
136
+ def screens(self, pages: Optional[list[str]] = None) -> list[dict]:
137
+ f = self.ws / "screens.json"
138
+ if pages is None and f.exists():
139
+ return json.loads(f.read_text())
140
+ return find_screens(self.legacy, pages)
141
+
142
+ def load_spec(self, sid: str) -> Spec:
143
+ return Spec.model_validate_json((self.ws / "specs" / f"{sid}.json").read_text(encoding="utf-8"))
144
+
145
+ def load_inventory(self, sid: str) -> dict:
146
+ return json.loads((self.ws / "inventory" / f"{sid}.json").read_text(encoding="utf-8"))
147
+
148
+ def approvals(self) -> dict:
149
+ f = self.ws / "approvals.json"
150
+ return json.loads(f.read_text()) if f.exists() else {}
151
+
152
+ def approve(self, sid: str, gate: str = "spec", by: str = "") -> None:
153
+ with self._state_lock:
154
+ a = self.approvals()
155
+ a.setdefault(sid, {})[gate] = {"at": _now(), "by": by}
156
+ self._write_atomic(self.ws / "approvals.json", json.dumps(a, indent=2))
157
+ stage = "approve_spec" if gate == "spec" else "approve"
158
+ self.mark(sid, stage, "done", f"approved {by}".strip())
159
+
160
+ # -------------------------------------------------------------- stages
161
+ def stage_inputs(self, offline: bool) -> None:
162
+ self.mark(None, "inputs", "running")
163
+ if offline:
164
+ if not (self.ws / "legacy" / "site.json").exists():
165
+ self.mark(None, "inputs", "failed", "offline run needs an existing extraction in the workspace")
166
+ raise StageBlocked("no extracted site in the workspace")
167
+ self.mark(None, "inputs", "done", "offline: using the existing extraction")
168
+ return
169
+ web = self.sp.web()
170
+ self.mark(None, "inputs", "done", f"connected to '{web.get('Title')}'")
171
+
172
+ def stage_extract(self, pages: Optional[list[str]], offline: bool) -> None:
173
+ self.require(None, "extract")
174
+ if offline:
175
+ self.mark(None, "extract", "done", "offline: skipped")
176
+ return
177
+ from .sharepoint.extract import Extractor
178
+ self.mark(None, "extract", "running")
179
+ man = Extractor(self.sp, self.ws, self.progress).run(pages, self.cfg.sharepoint.sample_items)
180
+ s = man["summary"]
181
+ self.mark(None, "extract", "done", f"{s['lists']} lists, {s['pages']} pages, {s['files']} custom files, {s['errors']} errors", summary=s)
182
+
183
+ def stage_inventory(self, pages: Optional[list[str]]) -> list[dict]:
184
+ self.require(None, "inventory")
185
+ legacy = self.legacy
186
+ scrs = find_screens(legacy, pages)
187
+ (self.ws / "screens.json").write_text(json.dumps(scrs, indent=2), encoding="utf-8")
188
+ (self.ws / "inventory").mkdir(exist_ok=True)
189
+ for sc in scrs:
190
+ self.mark(sc["id"], "inventory", "running")
191
+ inv = build_inventory(legacy, sc)
192
+ (self.ws / "inventory" / f"{sc['id']}.json").write_text(json.dumps(inv, indent=2), encoding="utf-8")
193
+ st = inv["stats"]
194
+ self.mark(sc["id"], "inventory", "done",
195
+ f"{st['custom_scripts']} custom scripts, {st['behaviors_must']} behaviours to carry, {st['data_calls']} data calls, {st['unresolved']} unresolved",
196
+ stats=st)
197
+ return scrs
198
+
199
+ def _spec_one(self, sid: str) -> None:
200
+ self.require(sid, "spec")
201
+ inv = self.load_inventory(sid)
202
+ inv_hash = hashlib.sha256(json.dumps(inv, sort_keys=True).encode()).hexdigest()[:16]
203
+ prev = self.state["screens"].get(sid, {}).get("spec") or {}
204
+ if prev.get("status") == "done" and prev.get("inventory_hash") == inv_hash and (self.ws / "specs" / f"{sid}.json").exists():
205
+ self.progress(f"[spec] {sid}: unchanged since the last run (legacy source identical), keeping the spec")
206
+ if self.approvals().get(sid, {}).get("spec") or self.cfg.pipeline.auto_approve:
207
+ if self.status(sid, "approve_spec") != "done":
208
+ self.approve(sid, "spec", "auto_approve" if self.cfg.pipeline.auto_approve else "")
209
+ return
210
+ if prev.get("inventory_hash") and prev.get("inventory_hash") != inv_hash:
211
+ a = self.approvals()
212
+ if a.get(sid, {}).pop("spec", None):
213
+ (self.ws / "approvals.json").write_text(json.dumps(a, indent=2), encoding="utf-8")
214
+ self.progress(f"[spec] {sid}: legacy source changed, previous spec approval withdrawn")
215
+ self.mark(sid, "spec", "running")
216
+ base = base_spec(inv)
217
+ (self.ws / "specs").mkdir(exist_ok=True)
218
+ rep0 = check(base, inv)
219
+ if rep0.ok:
220
+ spec, note = base, "base spec already covers everything (no agent needed)"
221
+ else:
222
+ spec, res = run_spec_agent(self.agent("spec", "json"), self.legacy, inv, base)
223
+ self._usage(sid, "spec", res)
224
+ note = f"spec agent accepted on attempt {res.attempts}"
225
+ rep = check(spec, inv)
226
+ (self.ws / "specs" / f"{sid}.json").write_text(spec.model_dump_json(indent=2, exclude_none=True), encoding="utf-8")
227
+ (self.ws / "specs" / f"{sid}.coverage.md").write_text(coverage_markdown(spec, inv, rep), encoding="utf-8")
228
+ self.mark(sid, "spec", "done", f"{rep.summary()}; {note}", rules=len(spec.rules), open_questions=len(spec.open_questions), inventory_hash=inv_hash)
229
+ if self.cfg.pipeline.auto_approve:
230
+ self.approve(sid, "spec", "auto_approve")
231
+ elif self.approvals().get(sid, {}).get("spec"):
232
+ self.mark(sid, "approve_spec", "done", "approved earlier")
233
+ else:
234
+ self.mark(sid, "approve_spec", "waiting", f"review workspace/specs/{sid}.coverage.md, then run: sp2react approve {sid}")
235
+
236
+ def stage_spec(self, sids: list[str]) -> None:
237
+ self._parallel("spec", sids, self._spec_one)
238
+
239
+ def _parallel(self, stage: str, sids: list[str], fn) -> None:
240
+ with cf.ThreadPoolExecutor(max_workers=max(1, self.cfg.pipeline.max_concurrency)) as pool:
241
+ futs = {pool.submit(fn, sid): sid for sid in sids}
242
+ for f, sid in futs.items():
243
+ try:
244
+ f.result(timeout=self.cfg.pipeline.agent_timeout * (self.cfg.pipeline.agent_retries + 1))
245
+ except StageBlocked as e:
246
+ self.progress(f"[{stage}] {sid}: blocked - {e}")
247
+ except AgentFailed as e:
248
+ self.mark(sid, stage, "failed", f"agent output kept failing validation; see {e.transcript}", problems=e.problems[:20])
249
+ except cf.TimeoutError:
250
+ self.mark(sid, stage, "failed", f"timed out after {self.cfg.pipeline.agent_timeout}s per attempt")
251
+ except Exception as e: # keep other screens going
252
+ log.exception("%s %s", stage, sid)
253
+ self.mark(sid, stage, "failed", f"{type(e).__name__}: {e}")
254
+
255
+ def approved_sids(self) -> list[str]:
256
+ return [sid for sid in self.state["screens"] if self.status(sid, "approve_spec") == "done" and (self.ws / "specs" / f"{sid}.json").exists()]
257
+
258
+ def stage_convert(self, sids: list[str]) -> None:
259
+ ready = [s for s in sids if self.status(s, "approve_spec") == "done"]
260
+ for s in set(sids) - set(ready):
261
+ self.progress(f"[convert] {s}: waiting for spec approval")
262
+ if not ready:
263
+ return
264
+ all_specs = [self.load_spec(s) for s in self.approved_sids()]
265
+ for s in ready:
266
+ self.mark(s, "convert", "running", "scaffold")
267
+ t0 = time.time()
268
+ export_dir = self.stage_migrate(all_specs)
269
+ if self.cfg.pipeline.data_source == "database":
270
+ mf = Path(self.cfg.database.mapping)
271
+ if not mf.exists() or self.status(None, "map_db") == "failed":
272
+ for s in ready:
273
+ self.mark(s, "convert", "failed", "data_source=database needs a reviewed list->table mapping: run `sp2react map-db` first")
274
+ return
275
+ info = generate(self.out, all_specs, self.legacy, export_dir, self.cfg.pipeline.data_source,
276
+ Path(self.cfg.database.mapping) if self.cfg.pipeline.data_source == "database" else None)
277
+ self.progress(f"[convert] scaffold written in {time.time() - t0:.1f}s ({info['files']} files)")
278
+ routes = [{"screen": sp.screen.id, "kind": sp.screen.kind, "route": sp.screen.route, "list": sp.screen.list,
279
+ "legacy_urls": sp.screen.legacy_urls} for sp in all_specs]
280
+
281
+ tasks = []
282
+ for sid in ready:
283
+ spec = self.load_spec(sid)
284
+ inv = self.load_inventory(sid)
285
+ if spec.screen.kind == "page":
286
+ tasks.append((sid, "page", lambda spec=spec, inv=inv: run_page_agent(self.agent("page", "files"), self.legacy, inv, spec, routes, self.out)))
287
+ for r in spec.rules:
288
+ if r.kind == "custom":
289
+ tasks.append((sid, f"handler:{r.params['handler']}", lambda spec=spec, inv=inv, r=r: run_handler_agent(self.agent("handler", "files"), self.legacy, inv, spec, r, self.out)))
290
+ failed: dict[str, str] = {}
291
+ if tasks:
292
+ self.progress(f"[convert] {len(tasks)} agent task(s) in parallel (max {self.cfg.pipeline.max_concurrency})")
293
+ with cf.ThreadPoolExecutor(max_workers=max(1, self.cfg.pipeline.max_concurrency)) as pool:
294
+ futs = [(sid, name, pool.submit(fn)) for sid, name, fn in tasks]
295
+ for sid, name, fut in futs:
296
+ try:
297
+ res = fut.result(timeout=self.cfg.pipeline.agent_timeout * (self.cfg.pipeline.agent_retries + 1))
298
+ self._usage(sid, "convert", res)
299
+ self.progress(f"[convert] {sid} {name}: accepted on attempt {res.attempts}")
300
+ except AgentFailed as e:
301
+ failed[sid] = f"{name}: output kept failing validation ({e.problems[:3]}); transcript {e.transcript}"
302
+ except cf.TimeoutError:
303
+ failed[sid] = f"{name}: timed out"
304
+ except Exception as e:
305
+ failed[sid] = f"{name}: {type(e).__name__}: {e}"
306
+ refresh_registries(self.out, all_specs)
307
+ for sid in ready:
308
+ if sid in failed:
309
+ self.mark(sid, "convert", "failed", failed[sid])
310
+ else:
311
+ n = sum(1 for t in tasks if t[0] == sid)
312
+ self.mark(sid, "convert", "done", f"scaffold + {n} agent file task(s)" if n else "scaffold only (form engine renders this screen from the spec)")
313
+
314
+ def _review_one(self, sid: str) -> None:
315
+ self.require(sid, "review")
316
+ self.mark(sid, "review", "running")
317
+ res = run_review_agent(self.agent("review", "json"), self.legacy, self.load_inventory(sid), self.load_spec(sid), self.out)
318
+ self._usage(sid, "review", res)
319
+ (self.ws / "reviews").mkdir(exist_ok=True)
320
+ (self.ws / "reviews" / f"{sid}.json").write_text(json.dumps(res.output, indent=2), encoding="utf-8")
321
+ blockers = [f for f in res.output["findings"] if f["severity"] in ("blocker", "major")]
322
+ self.mark(sid, "review", "done", f"verdict {res.output['verdict']}, {len(blockers)} blocking finding(s), {len(res.output['findings'])} total",
323
+ verdict=res.output["verdict"], blocking=len(blockers))
324
+
325
+ def stage_review(self, sids: list[str]) -> None:
326
+ self._parallel("review", [s for s in sids if self.status(s, "convert") == "done"], self._review_one)
327
+
328
+ # ------------------------------------------------------------ data
329
+ @property
330
+ def export_dir(self) -> Path:
331
+ return self.ws / "data-export"
332
+
333
+ def stage_migrate(self, specs: list[Spec]) -> Optional[Path]:
334
+ """Export every item of every list the approved screens use (part of convert)."""
335
+ if self.cfg.pipeline.data_source == "sharepoint":
336
+ self.mark(None, "migrate", "skipped", "data_source=sharepoint: the app reads and writes the live lists")
337
+ return None
338
+ if self.cfg.pipeline.data_source == "database":
339
+ self.mark(None, "migrate", "skipped", "data_source=database: the app uses your existing tables through the mapping")
340
+ return None
341
+ lists = sorted({name for sp in specs for name in sp.lists})
342
+ if getattr(self, "_offline", False):
343
+ if all((self.export_dir / f"{k}.jsonl").exists() for k in lists):
344
+ self.mark(None, "migrate", "done", "offline: reusing the existing full export")
345
+ return self.export_dir
346
+ self.mark(None, "migrate", "warning", "offline and no full export: the app is seeded from the extraction sample only (not a migration)")
347
+ return None
348
+ from .migrate import export_lists
349
+ self.mark(None, "migrate", "running", f"exporting {len(lists)} list(s)")
350
+ summary = export_lists(self.sp, self.legacy.site, lists, self.export_dir, progress=self.progress)
351
+ total = sum(v["items"] for v in summary.values())
352
+ self.mark(None, "migrate", "done", f"{total} items and {sum(v['attachments'] for v in summary.values())} attachments from {len(lists)} lists", lists=summary)
353
+ return self.export_dir
354
+
355
+ def mapping_lists(self, specs: list[Spec]) -> dict:
356
+ out: dict = {}
357
+ for sp in specs:
358
+ for name, l in sp.lists.items():
359
+ cur = out.setdefault(name, {"title": l.title, "fields": []})
360
+ known = {f["name"] for f in cur["fields"]}
361
+ cur["fields"] += [f for f in l.fields if f["name"] not in known]
362
+ return out
363
+
364
+ def stage_map_db(self, specs: list[Spec]) -> bool:
365
+ """data_source=database: map lists onto the existing tables (schema only) -> db-mapping.json + .md."""
366
+ from .config import secret
367
+ from .dbmap import draft, reflect, report, validate
368
+ from .agents.tasks import run_dbmap_agent
369
+ lists = self.mapping_lists(specs)
370
+ self.mark(None, "map_db", "running", f"{len(lists)} list(s)")
371
+ db = reflect(secret(self.cfg.database.url_env, "database URL"), self.cfg.database.schema_name)
372
+ (self.ws / "db-schema.json").write_text(json.dumps(db, indent=1), encoding="utf-8")
373
+ mapping_file = Path(self.cfg.database.mapping)
374
+ if mapping_file.exists(): # keep a mapping you already reviewed; only extend it
375
+ mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
376
+ missing = {k: v for k, v in lists.items() if k not in mapping.get("lists", {})}
377
+ if missing:
378
+ extra, _ = draft(missing, db)
379
+ mapping.setdefault("lists", {}).update(extra["lists"])
380
+ notes: list[str] = []
381
+ else:
382
+ mapping, notes = draft(lists, db)
383
+ problems, warnings = validate(mapping, lists, db)
384
+ agent_notes: list[str] = []
385
+ if problems:
386
+ mapping, agent_notes, res = run_dbmap_agent(self.agent("dbmap", "json"), lists, db, mapping, notes + problems)
387
+ self._usage("database", "map_db", res)
388
+ problems, warnings = validate(mapping, lists, db)
389
+ mapping_file.write_text(json.dumps(mapping, indent=2), encoding="utf-8")
390
+ md = report(mapping, lists, db, problems, warnings + agent_notes)
391
+ (self.ws / "db-mapping.md").write_text(md, encoding="utf-8")
392
+ ok = not problems
393
+ self.mark(None, "map_db", "done" if ok else "failed",
394
+ f"{len(mapping.get('lists', {}))} list(s) mapped, {len(warnings)} warning(s); review {mapping_file.name} and db-mapping.md" if ok
395
+ else f"{len(problems)} problem(s); see db-mapping.md")
396
+ return ok
397
+
398
+ def stage_reconcile(self, specs: list[Spec]) -> dict:
399
+ """Read everything back through the app's repository and compare with the SharePoint export."""
400
+ from .migrate import read_through_app, reconcile, report_markdown
401
+ if self.cfg.pipeline.data_source == "sharepoint":
402
+ self.mark(None, "reconcile", "skipped", "data_source=sharepoint")
403
+ return {"ok": True, "lists": {}, "skipped": True}
404
+ if self.cfg.pipeline.data_source == "database" and not self.cfg.item_reads_allowed:
405
+ self.mark(None, "reconcile", "skipped", "no list items are read in database mode; set security.sharepoint_item_reads: true to compare the lists with your tables")
406
+ return {"ok": True, "lists": {}, "skipped": True}
407
+ database = self.cfg.pipeline.data_source == "database"
408
+ if database and not getattr(self, "_offline", False):
409
+ from .migrate import export_lists # read-only comparison copy of the lists, kept in the workspace
410
+ export_lists(self.sp, self.legacy.site, sorted({n for sp in specs for n in sp.lists}), self.export_dir, progress=self.progress)
411
+ if not (self.export_dir / "export.json").exists():
412
+ self.mark(None, "reconcile", "warning", "no full export to reconcile against (seeded from the sample)")
413
+ return {"ok": True, "lists": {}, "skipped": True}
414
+ lists = {}
415
+ for sp in specs:
416
+ for name, l in sp.lists.items():
417
+ lists.setdefault(name, {"fields": l.fields})
418
+ app_items = read_through_app(self.out, self.ws / "reconcile.db", load=not database, env=self._app_env())
419
+ rep = reconcile(self.export_dir, app_items, lists)
420
+ meta = json.loads((self.export_dir / "export.json").read_text())
421
+ (self.ws / "reconciliation.md").write_text(report_markdown(rep, meta), encoding="utf-8")
422
+ (self.ws / "reconciliation.json").write_text(json.dumps(rep, indent=1, default=str), encoding="utf-8")
423
+ n = sum(l["source_items"] for l in rep["lists"].values())
424
+ bad = [k for k, l in rep["lists"].items() if not l["ok"]]
425
+ self.mark(None, "reconcile", "done" if rep["ok"] else "failed",
426
+ f"{n} items in {len(rep['lists'])} lists match field by field" if rep["ok"] else f"mismatches in {', '.join(bad)}; see reconciliation.md")
427
+ return rep
428
+
429
+ def stage_test_and_fix(self, sids: list[str]) -> None:
430
+ sids = [s for s in sids if self.status(s, "review") == "done"]
431
+ if not sids:
432
+ return
433
+ handlers = {}
434
+ for sid in sids:
435
+ for r in self.load_spec(sid).rules:
436
+ if r.kind == "custom":
437
+ handlers[r.params["handler"]] = sid
438
+ max_iter = self.cfg.pipeline.max_fix_iterations
439
+ specs_now = [self.load_spec(s) for s in self.approved_sids()]
440
+ rec = self.stage_reconcile(specs_now)
441
+ if not rec["ok"]:
442
+ bad = {k for k, l in rec["lists"].items() if not l["ok"]}
443
+ for sid in sids:
444
+ if bad & set(self.load_spec(sid).lists):
445
+ self.mark(sid, "test", "failed", f"data reconciliation failed for {', '.join(sorted(bad))}; see reconciliation.md")
446
+ self.mark(sid, "fix", "failed", "data mismatches are fixed in the migration mapping or the source data, not by an agent")
447
+ sids = [s for s in sids if not (bad & set(self.load_spec(s).lists))]
448
+ if not sids:
449
+ return
450
+ for it in range(0, max_iter + 1):
451
+ for sid in sids:
452
+ self.mark(sid, "test", "running", f"iteration {it}")
453
+ checks = run_all(self.out, self.cfg.pipeline.run_builds, self._app_env())
454
+ (self.ws / "tests").mkdir(exist_ok=True)
455
+ (self.ws / "tests" / f"iteration-{it}.md").write_text(
456
+ "\n\n".join(f"## {c.name}: {'skipped' if c.skipped else 'ok' if c.ok else 'FAILED'}\n```\n{c.tail(20000)}\n```" for c in checks), encoding="utf-8")
457
+ routed = failures_by_screen(checks, sids, handlers)
458
+ reviews = {sid: json.loads((self.ws / "reviews" / f"{sid}.json").read_text()) for sid in sids if (self.ws / "reviews" / f"{sid}.json").exists()}
459
+ blocking = {sid: [f for f in r["findings"] if f["severity"] in ("blocker", "major")] for sid, r in reviews.items()}
460
+ summary = "; ".join(f"{c.name}: {'skipped' if c.skipped else 'ok' if c.ok else 'failed'}" for c in checks)
461
+ todo = {sid for sid in sids if routed.get(sid) or blocking.get(sid)}
462
+ infra = routed.get("")
463
+ for sid in sids:
464
+ st = "failed" if (sid in todo or infra) else "done"
465
+ self.mark(sid, "test", st, summary, iteration=it)
466
+ if not todo and not infra:
467
+ for sid in sids:
468
+ self.mark(sid, "fix", "done" if it else "skipped", f"{it} fix iteration(s)")
469
+ self._screen_gate(sid)
470
+ return
471
+ if it == max_iter:
472
+ break
473
+ if infra and not todo:
474
+ for sid in sids:
475
+ self.mark(sid, "fix", "failed", "failures are in shared scaffold code or the environment, not in agent-written files; see workspace/tests")
476
+ return
477
+ for sid in sorted(todo):
478
+ self.mark(sid, "fix", "running", f"iteration {it + 1}")
479
+ failures = routed.get(sid, "")
480
+ if blocking.get(sid):
481
+ failures += "\n\n## Review findings to fix\n" + json.dumps(blocking[sid], indent=1)
482
+ try:
483
+ res, new_spec = run_fix_agent(self.agent("fix", "files"), self.load_inventory(sid), self.load_spec(sid), failures, self.out, f"{sid}-iter{it + 1}")
484
+ self._usage(sid, "fix", res)
485
+ if new_spec is not None:
486
+ (self.ws / "specs" / f"{sid}.json").write_text(new_spec.model_dump_json(indent=2, exclude_none=True), encoding="utf-8")
487
+ js = new_spec.model_dump_json(indent=2, exclude_none=True)
488
+ (self.out / "backend" / "specs" / f"{sid}.json").write_text(js, encoding="utf-8")
489
+ (self.out / "frontend" / "src" / "specs" / f"{sid}.json").write_text(js, encoding="utf-8")
490
+ if blocking.get(sid): # re-review after a fix for review findings
491
+ self._review_one(sid)
492
+ except AgentFailed as e:
493
+ self.mark(sid, "fix", "failed", f"fix agent output kept failing validation; see {e.transcript}")
494
+ for sid in sids:
495
+ if self.status(sid, "test") != "done":
496
+ self.mark(sid, "fix", "failed", f"still failing after {max_iter} iteration(s); see workspace/tests")
497
+
498
+ def _app_env(self) -> dict:
499
+ if self.cfg.pipeline.data_source != "database":
500
+ return {}
501
+ import os
502
+ url = os.environ.get(self.cfg.database.url_env)
503
+ return {"DATA_SOURCE": "database", "MAPPING_FILE": str(self.out / "backend" / "mapping.json"), **({"DB_URL": url} if url else {})}
504
+
505
+ def _screen_gate(self, sid: str) -> None:
506
+ if self.cfg.pipeline.auto_approve:
507
+ self.approve(sid, "screen", "auto_approve")
508
+ elif self.approvals().get(sid, {}).get("screen"):
509
+ self.mark(sid, "approve", "done", "approved earlier")
510
+ else:
511
+ self.mark(sid, "approve", "waiting", f"run the app, compare with the original, then: sp2react approve {sid} --screen")
512
+
513
+ # ----------------------------------------------------------------- run
514
+ def run(self, pages: Optional[list[str]] = None, offline: bool = False, until: str = "approve") -> dict:
515
+ self._offline = offline
516
+ stop = STAGES.index(until)
517
+ self.stage_inputs(offline)
518
+ if stop >= 1:
519
+ self.stage_extract(pages, offline)
520
+ sids: list[str] = []
521
+ if stop >= 2:
522
+ sids = [s["id"] for s in self.stage_inventory(pages)]
523
+ if stop >= 3:
524
+ self.stage_spec(sids)
525
+ if stop >= 5 and self.cfg.pipeline.data_source == "database" and self.approved_sids():
526
+ try:
527
+ self.stage_map_db([self.load_spec(s) for s in self.approved_sids()])
528
+ except Exception as e: # missing DB_URL, driver, network...
529
+ self.mark(None, "map_db", "failed", f"{type(e).__name__}: {e}")
530
+ if stop >= 5:
531
+ self.stage_convert(sids)
532
+ if stop >= 6:
533
+ self.stage_review(sids)
534
+ if stop >= 7:
535
+ self.stage_test_and_fix(sids)
536
+ write_report(self)
537
+ return self.state
538
+
539
+
540
+ def coverage_markdown(spec: Spec, inv: dict, rep) -> str:
541
+ beh = {b["id"]: b for b in inv["behaviors"]}
542
+ lines = [f"# {spec.screen.title} ({spec.screen.id})", "", rep.summary(), "",
543
+ "| Legacy behaviour | Kind | Code | Carried by |", "|---|---|---|---|"]
544
+ for m in rep.matrix:
545
+ b = beh[m["behavior"]]
546
+ code = (b.get("code") or "").replace("|", "\\|")[:80]
547
+ lines.append(f"| `{m['behavior']}` | {m['kind']} | `{code}` | {', '.join(m['covered_by']) or '**missing**'} |")
548
+ lines += ["", "## Rules", ""]
549
+ for r in spec.rules:
550
+ lines.append(f"- **{r.id}** {r.kind} {r.field or ''} {('modes=' + ','.join(r.modes)) if r.modes else ''} "
551
+ f"{('when=' + json.dumps(r.when)) if r.when else ''} {('“' + r.message + '”') if r.message else ''}")
552
+ if spec.not_carried:
553
+ lines += ["", "## Not carried (with reasons)", ""] + [f"- {', '.join(n.covers)}: {n.reason}" for n in spec.not_carried]
554
+ if spec.open_questions:
555
+ lines += ["", "## Open questions", ""] + [f"- {q}" for q in spec.open_questions]
556
+ if rep.problems:
557
+ lines += ["", "## Problems", ""] + [f"- {p}" for p in rep.problems]
558
+ return "\n".join(lines) + "\n"
559
+
560
+
561
+ def write_report(p: Pipeline) -> Path:
562
+ lines = ["# sp2react run report", "", f"Updated {_now()}", "", "| Screen | " + " | ".join(STAGES[2:]) + " |", "|---|" + "---|" * len(STAGES[2:])]
563
+ for sid, st in p.state["screens"].items():
564
+ lines.append(f"| {sid} | " + " | ".join((st.get(s) or {}).get("status", "") for s in STAGES[2:]) + " |")
565
+ for k in ("migrate", "reconcile"):
566
+ g = p.state["global"].get(k)
567
+ if g:
568
+ lines.append(f"\n**Data {k}:** {g['status']} - {g.get('detail', '')}")
569
+ lines += ["", "## Details", ""]
570
+ for sid, st in p.state["screens"].items():
571
+ lines.append(f"### {sid}")
572
+ for s in STAGES[2:]:
573
+ if st.get(s):
574
+ lines.append(f"- **{s}**: {st[s]['status']} - {st[s].get('detail', '')}")
575
+ u = p.state["usage"].get(sid)
576
+ if u:
577
+ tot_in = sum(v["input_tokens"] for v in u.values())
578
+ tot_out = sum(v["output_tokens"] for v in u.values())
579
+ lines.append(f"- model usage: {tot_in:,} input / {tot_out:,} output tokens across " + ", ".join(f"{k} ({v['attempts']} call(s))" for k, v in u.items()))
580
+ lines.append("")
581
+ f = p.ws / "report.md"
582
+ f.write_text("\n".join(lines), encoding="utf-8")
583
+ return f
File without changes