@suiflex/suitest-mcp 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suiflex/suitest-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -388,15 +388,22 @@ def blackbox_publish_results(**kwargs: Any) -> dict[str, Any]:
388
388
  import os as _os
389
389
  import re as _re
390
390
 
391
+ from suitest_lifecycle.retest import rewrite_project_id
392
+
391
393
  project_id = str(kwargs.pop("project_id", "") or "")
392
394
  suite_name = str(kwargs.pop("suite_name", "") or "")
395
+ # EXPLICIT recreate opt-in — mirrors the lifecycle run tools. Without it a
396
+ # stale binding fails this publish instead of minting a fresh project.
397
+ recreate = bool(kwargs.pop("recreate_project", False))
393
398
  config_path = str(kwargs.get("config_path", "") or "")
394
399
  ui, paths = _resolve(**kwargs)
395
- if config_path and not project_id:
400
+ if config_path:
396
401
  from suitest_lifecycle.config import load_config
397
402
 
398
403
  cfg = load_config(config_path)
399
- project_id = cfg.publish.project_id
404
+ if not project_id:
405
+ project_id = cfg.publish.project_id
406
+ recreate = recreate or cfg.publish.recreate
400
407
  # No project configured → the server finds-or-creates one by a slug derived
401
408
  # from the target host. Publishing is MANDATORY in the blackbox pipeline;
402
409
  # "no project yet" is not an excuse to keep results local.
@@ -424,6 +431,66 @@ def blackbox_publish_results(**kwargs: Any) -> dict[str, Any]:
424
431
 
425
432
  try:
426
433
  with SuitestClient(api_url, token=token, timeout=180.0) as client:
434
+ # --- project binding gate (before any upload/insert) ----------- #
435
+ binding: dict[str, Any] = {"status": "first_setup", "action": "will_create_by_slug"}
436
+ if project_id:
437
+ binding = {
438
+ "status": "unverified",
439
+ "action": "server_unreachable",
440
+ "projectId": project_id,
441
+ }
442
+ try:
443
+ resolved = client.resolve_project(
444
+ project_id=project_id,
445
+ project_slug=project_slug,
446
+ project_name=project_name,
447
+ )
448
+ except Exception:
449
+ # Resolve endpoint unreachable/older server: proceed — the
450
+ # publish itself still 404s a stale id without inserting.
451
+ resolved = None
452
+ if resolved is not None:
453
+ status = str(resolved.get("status", "missing"))
454
+ if status == "valid":
455
+ binding = {
456
+ "status": "valid",
457
+ "action": "reused_existing_project",
458
+ "projectId": project_id,
459
+ }
460
+ elif status == "repaired":
461
+ project_id = str(resolved.get("projectId", ""))
462
+ binding = {
463
+ "status": "repaired",
464
+ "action": "rebound_by_" + str(resolved.get("matchedBy", "match")),
465
+ "projectId": project_id,
466
+ }
467
+ if config_path:
468
+ rewrite_project_id(Path(config_path), project_id)
469
+ elif recreate:
470
+ binding = {
471
+ "status": "recreate_requested",
472
+ "action": "will_recreate_by_slug",
473
+ }
474
+ project_id = "" # server find-or-creates by slug below
475
+ else:
476
+ return _envelope(
477
+ False,
478
+ f"stale project binding: projectId '{project_id}' not found in "
479
+ "the workspace and no unambiguous project matched — nothing "
480
+ "was published",
481
+ data={
482
+ "projectBinding": {
483
+ "status": "missing",
484
+ "action": "fail",
485
+ "projectId": project_id,
486
+ "candidates": resolved.get("candidates", []),
487
+ }
488
+ },
489
+ errors=[
490
+ "fix publish.projectId (or the project_id argument), or "
491
+ "re-run with recreate_project=true to create a new project"
492
+ ],
493
+ )
427
494
 
428
495
  def _up(path: str, mime: str) -> str:
429
496
  try:
@@ -505,26 +572,41 @@ def blackbox_publish_results(**kwargs: Any) -> dict[str, Any]:
505
572
  )
506
573
  imported = client.bulk_import_cases(
507
574
  project_id=project_id,
508
- project_slug=project_slug,
509
- project_name=project_name,
575
+ # Slug fallback ONLY when no validated id — an explicit id must
576
+ # never silently degrade into a find-or-create.
577
+ project_slug="" if project_id else project_slug,
578
+ project_name="" if project_id else project_name,
510
579
  suite_name=suite,
511
580
  mode="frontend",
512
581
  cases=cases_payload,
582
+ # Current generation = the suite's alive set; an empty payload
583
+ # (nothing generated) must not stale the whole suite.
584
+ mark_stale=bool(cases_payload),
513
585
  )
586
+ resolved_id = str(imported.get("projectId", "") or "") or project_id
514
587
  run = client.ingest_run(
515
- project_id=project_id,
516
- project_slug=project_slug,
517
- project_name=project_name,
588
+ project_id=resolved_id,
589
+ project_slug="" if resolved_id else project_slug,
590
+ project_name="" if resolved_id else project_name,
518
591
  suite_name=suite,
519
592
  name=f"{suite} run",
520
593
  results=results_payload,
521
594
  )
522
595
  except SuitestAPIError as exc:
523
596
  return _envelope(False, f"publish failed: {exc}", errors=[str(exc)])
597
+ # Slug-based publish minted (or found) the project — pin its id so the next
598
+ # blackbox publish is an explicit-id retest, never a re-create.
599
+ if config_path and resolved_id and binding["status"] in ("first_setup", "recreate_requested"):
600
+ rewrite_project_id(Path(config_path), resolved_id)
524
601
  return _envelope(
525
602
  True,
526
603
  f"published: {len(imported.get('imported', []))} case(s), run {run.get('runId')}",
527
- data={"imported": imported, "run": run},
604
+ data={
605
+ "imported": imported,
606
+ "run": run,
607
+ "projectBinding": {**binding, "projectId": resolved_id},
608
+ "staleCases": imported.get("stale", []),
609
+ },
528
610
  )
529
611
 
530
612
 
@@ -92,6 +92,12 @@ def main(argv: list[str] | None = None) -> int:
92
92
 
93
93
  test = sub.add_parser("test", help="Run the full config-driven lifecycle")
94
94
  test.add_argument("--config", default="suitest.config.json")
95
+ test.add_argument(
96
+ "--recreate-project",
97
+ action="store_true",
98
+ help="EXPLICITLY recreate the Suitest project when publish.projectId is "
99
+ "stale and repair finds no match (otherwise a stale binding fails the run)",
100
+ )
95
101
 
96
102
  sub.add_parser("mcp", help="Serve the stdio MCP server")
97
103
 
@@ -109,7 +115,10 @@ def main(argv: list[str] | None = None) -> int:
109
115
  from suitest_lifecycle.config import load_config
110
116
  from suitest_lifecycle.orchestrator import run_lifecycle
111
117
 
112
- res = run_lifecycle(load_config(args.config))
118
+ cfg = load_config(args.config)
119
+ if args.recreate_project:
120
+ cfg.publish.recreate = True
121
+ res = run_lifecycle(cfg)
113
122
  print(res.summary)
114
123
  for step in res.steps:
115
124
  print(f" - {step}")
@@ -76,6 +76,11 @@ class PublishConfig:
76
76
  workspace_id: str = ""
77
77
  project_id: str = ""
78
78
  suite_name: str = ""
79
+ # EXPLICIT recreate opt-in: when the configured projectId no longer exists
80
+ # and repair finds no match, a fresh project is created ONLY if this is set
81
+ # (config keys ``recreateProject``/``resetProjectBinding``, env
82
+ # SUITEST_RECREATE_PROJECT=1, or the MCP tool arg ``recreate_project``).
83
+ recreate: bool = False
79
84
 
80
85
 
81
86
  @dataclass
@@ -264,6 +269,8 @@ def load_config(path: str | Path) -> Config:
264
269
  publish = PublishConfig()
265
270
  pub_raw = raw.get("publish", {})
266
271
  if isinstance(pub_raw, dict):
272
+ import os
273
+
267
274
  publish = PublishConfig(
268
275
  enabled=bool(pub_raw.get("enabled", False)),
269
276
  api_url=str(pub_raw.get("apiUrl", "http://localhost:4000")).rstrip("/"),
@@ -271,6 +278,11 @@ def load_config(path: str | Path) -> Config:
271
278
  workspace_id=str(pub_raw.get("workspaceId", "")),
272
279
  project_id=str(pub_raw.get("projectId", "")),
273
280
  suite_name=str(pub_raw.get("suiteName", "")),
281
+ recreate=bool(
282
+ pub_raw.get("recreateProject", False)
283
+ or pub_raw.get("resetProjectBinding", False)
284
+ or os.environ.get("SUITEST_RECREATE_PROJECT", "") in ("1", "true")
285
+ ),
274
286
  )
275
287
 
276
288
  ids_raw = raw.get("testIds", [])
@@ -22,6 +22,10 @@ if TYPE_CHECKING:
22
22
 
23
23
  PROTOCOL_VERSION = "2024-11-05"
24
24
 
25
+ # Run tools accept the explicit recreate opt-in (goal: recreate NEVER happens
26
+ # implicitly — only via this flag or the publish.recreateProject config key).
27
+ RECREATE_TOOLS = frozenset({"run_tests", "run_backend_tests", "run_frontend_tests"})
28
+
25
29
  _TOOL_DESCRIPTIONS = {
26
30
  "analyze_project": "Static-analyze the target project; list endpoints (backend) or pages (frontend).",
27
31
  "generate_test_cases": "Analyze, build a PRD + test plan, and export runnable test files.",
@@ -74,6 +78,14 @@ _BLACKBOX_INPUT_SCHEMA: dict[str, object] = {
74
78
  "type": "string",
75
79
  "description": "Suitest project id to publish into (blackbox_publish_results)",
76
80
  },
81
+ "recreate_project": {
82
+ "type": "boolean",
83
+ "description": (
84
+ "EXPLICIT opt-in: recreate the project when the configured/passed "
85
+ "project id no longer exists and repair finds no match "
86
+ "(blackbox_publish_results). Without it a stale binding fails the publish."
87
+ ),
88
+ },
77
89
  "prd_file": {
78
90
  "type": "string",
79
91
  "description": "Markdown PRD path — PRD-driven semantic plan via the workspace LLM (blackbox_generate_playwright_tests)",
@@ -90,18 +102,29 @@ def _tool_schema(name: str) -> dict[str, object]:
90
102
  "description": _TOOL_DESCRIPTIONS.get(name, name),
91
103
  "inputSchema": _BLACKBOX_INPUT_SCHEMA,
92
104
  }
105
+ properties: dict[str, object] = {
106
+ "config_path": {
107
+ "type": "string",
108
+ "description": "Path to suitest.config.json",
109
+ "default": "suitest.config.json",
110
+ }
111
+ }
112
+ if name in RECREATE_TOOLS:
113
+ properties["recreate_project"] = {
114
+ "type": "boolean",
115
+ "description": (
116
+ "EXPLICIT opt-in: recreate the project when the configured "
117
+ "publish.projectId no longer exists and repair finds no match. "
118
+ "Without this flag a stale binding FAILS the run (nothing is inserted)."
119
+ ),
120
+ "default": False,
121
+ }
93
122
  return {
94
123
  "name": name,
95
124
  "description": _TOOL_DESCRIPTIONS.get(name, name),
96
125
  "inputSchema": {
97
126
  "type": "object",
98
- "properties": {
99
- "config_path": {
100
- "type": "string",
101
- "description": "Path to suitest.config.json",
102
- "default": "suitest.config.json",
103
- }
104
- },
127
+ "properties": properties,
105
128
  "required": ["config_path"],
106
129
  },
107
130
  }
@@ -124,7 +147,7 @@ def handle(message: dict[str, object]) -> dict[str, object] | None:
124
147
  {
125
148
  "protocolVersion": PROTOCOL_VERSION,
126
149
  "capabilities": {"tools": {}},
127
- "serverInfo": {"name": "suitest-lifecycle", "version": "0.1.0"},
150
+ "serverInfo": {"name": "suitest-lifecycle", "version": "0.1.1"},
128
151
  },
129
152
  )
130
153
  if method in ("notifications/initialized", "initialized"):
@@ -144,6 +167,11 @@ def handle(message: dict[str, object]) -> dict[str, object] | None:
144
167
  try:
145
168
  if str(name) in KWARG_TOOLS:
146
169
  envelope = tool(**arguments)
170
+ elif str(name) in RECREATE_TOOLS:
171
+ envelope = tool(
172
+ str(arguments.get("config_path", "suitest.config.json")),
173
+ bool(arguments.get("recreate_project", False)),
174
+ )
147
175
  else:
148
176
  envelope = tool(str(arguments.get("config_path", "suitest.config.json")))
149
177
  except Exception as exc: # defensive: never crash the server on a tool bug
@@ -27,6 +27,18 @@ from suitest_lifecycle.process import ProcessManager
27
27
  from suitest_lifecycle.publish import publish_results
28
28
  from suitest_lifecycle.readiness import wait_until_ready
29
29
  from suitest_lifecycle.report import write_all_reports
30
+ from suitest_lifecycle.retest import (
31
+ BindingResult,
32
+ build_fingerprint,
33
+ can_reuse_generated,
34
+ classify_results,
35
+ diff_fingerprint,
36
+ load_codegen_meta,
37
+ load_snapshot,
38
+ reconcile_codegen,
39
+ resolve_binding,
40
+ save_snapshot,
41
+ )
30
42
  from suitest_lifecycle.runner import run_tests
31
43
  from suitest_lifecycle.serialize import (
32
44
  code_summary_to_json,
@@ -48,6 +60,10 @@ class LifecycleResult:
48
60
  artifacts: list[str] = field(default_factory=list)
49
61
  errors: list[str] = field(default_factory=list)
50
62
  steps: list[str] = field(default_factory=list)
63
+ # Retest telemetry: mode, project binding, change detection, generated-code
64
+ # status, failure classification, next actions. Surfaced verbatim in the
65
+ # MCP envelope so agents can reason about WHY a retest behaved as it did.
66
+ retest: dict[str, object] = field(default_factory=dict)
51
67
 
52
68
 
53
69
  def _publish_step(pub: dict[str, object]) -> str:
@@ -58,6 +74,15 @@ def _publish_step(pub: dict[str, object]) -> str:
58
74
  return f"publish skipped — {pub.get('reason')}"
59
75
 
60
76
 
77
+ def _record_publish(pub: dict[str, object], steps: list[str], errors: list[str]) -> None:
78
+ """A failed publish never fails the run, but it must be LOUD: agents only
79
+ read the envelope's ``errors``, so a steps-only note is effectively silent."""
80
+ msg = _publish_step(pub)
81
+ steps.append(msg)
82
+ if not pub.get("published"):
83
+ errors.append(msg)
84
+
85
+
61
86
  def _today() -> str:
62
87
  return datetime.date.today().isoformat()
63
88
 
@@ -114,6 +139,67 @@ def _is_blackbox(config: Config) -> bool:
114
139
  return config.mode is Mode.FRONTEND and config.analysis_source == "blackbox"
115
140
 
116
141
 
142
+ # Stable identity fields for a discovered element — deliberately EXCLUDES
143
+ # volatile data (screenshot paths, dynamic visible text) so a retest against an
144
+ # unchanged app never false-positives as a UI change.
145
+ _ELEMENT_ID_FIELDS = (
146
+ "kind",
147
+ "testid",
148
+ "role",
149
+ "name",
150
+ "label",
151
+ "placeholder",
152
+ "dom_id",
153
+ "css",
154
+ "input_type",
155
+ )
156
+
157
+
158
+ def _crawl_elements(crawl: object | None) -> dict[str, object] | None:
159
+ """Per-route interactive-element identity from a live DOM crawl.
160
+
161
+ Feeds selector-level change detection (``selector_changed``). Returns None
162
+ when no crawl ran (repo-analysis runs have no element capture)."""
163
+ if crawl is None:
164
+ return None
165
+ page_elements = getattr(crawl, "page_elements", None)
166
+ page_testids = getattr(crawl, "page_testids", None)
167
+ pe = page_elements if isinstance(page_elements, dict) else {}
168
+ pt = page_testids if isinstance(page_testids, dict) else {}
169
+ if not pe and not pt:
170
+ return None
171
+ return {
172
+ str(route): {"elements": pe.get(route), "testids": pt.get(route)}
173
+ for route in sorted({*pe, *pt})
174
+ }
175
+
176
+
177
+ def _discovery_elements(pages: list[object]) -> dict[str, object]:
178
+ """Per-route element identity from a blackbox discovery (same purpose as
179
+ :func:`_crawl_elements`, different source shape)."""
180
+
181
+ def _ident(e: object, *, with_text: bool = False) -> dict[str, object]:
182
+ d: dict[str, object] = {f: getattr(e, f, "") for f in _ELEMENT_ID_FIELDS}
183
+ if with_text:
184
+ # Button labels are part of the UI contract (button_label_changed);
185
+ # link/input text is too dynamic to fingerprint.
186
+ d["text"] = getattr(e, "text", "")
187
+ return d
188
+
189
+ out: dict[str, object] = {}
190
+ for p in pages:
191
+ out[str(getattr(p, "route", ""))] = {
192
+ "inputs": [_ident(e) for e in getattr(p, "inputs", [])],
193
+ "buttons": [_ident(e, with_text=True) for e in getattr(p, "buttons", [])],
194
+ "links": [_ident(e) for e in getattr(p, "links", [])],
195
+ "hasTable": getattr(p, "has_table", False),
196
+ "hasForm": getattr(p, "has_form", False),
197
+ "hasModal": getattr(p, "has_modal", False),
198
+ "rowLocator": getattr(p, "row_locator", ""),
199
+ }
200
+ return out
201
+
202
+
117
203
  def generate_only(
118
204
  config: Config,
119
205
  summary: CodeSummary | None = None,
@@ -145,7 +231,54 @@ def generate_only(
145
231
  from suitest_lifecycle.llm_bridge import build_dom_context
146
232
 
147
233
  dom_context = build_dom_context(crawl, summary) # type: ignore[arg-type]
148
- cases = _export(config, cases, summary, paths, llm=codegen_llm, dom_context=dom_context)
234
+
235
+ # --- retest: change detection + generated-code reuse -------------------- #
236
+ import hashlib
237
+
238
+ fingerprint = build_fingerprint(summary, cases, _crawl_elements(crawl))
239
+ change_report = diff_fingerprint(load_snapshot(paths), fingerprint)
240
+ dom_digest = hashlib.sha256(dom_context.encode("utf-8")).hexdigest()[:16]
241
+ meta_prev = load_codegen_meta(paths)
242
+ reuse = (
243
+ not change_report["first"]
244
+ and not change_report["changed"]
245
+ and can_reuse_generated(cases, paths, meta_prev, dom_digest, config.codegen)
246
+ )
247
+ export_error = ""
248
+ stash: dict[str, str] = {}
249
+ if not reuse:
250
+ # Stash current files so a regen never silently destroys reviewed code:
251
+ # changed files are archived to history/, a failed regen is rolled back.
252
+ for entry in meta_prev.values():
253
+ fname = str(entry.get("file", ""))
254
+ fp = paths.test_file(fname) if fname else None
255
+ if fp is not None and fp.is_file():
256
+ stash[fname] = fp.read_text(encoding="utf-8")
257
+ try:
258
+ cases = _export(config, cases, summary, paths, llm=codegen_llm, dom_context=dom_context)
259
+ except Exception as exc: # regen failure → keep prior code, flag needs_review
260
+ export_error = f"{type(exc).__name__}: {exc}"
261
+ for fname, content in stash.items():
262
+ paths.test_file(fname).write_text(content, encoding="utf-8")
263
+ for c in cases:
264
+ prev_entry = meta_prev.get(c.title)
265
+ if prev_entry:
266
+ c.automation_file = str(prev_entry.get("file", ""))
267
+ _meta, gen_counts = reconcile_codegen(
268
+ cases,
269
+ paths,
270
+ meta_prev,
271
+ stash,
272
+ dom_digest,
273
+ config.codegen,
274
+ reused=reuse,
275
+ export_error=export_error,
276
+ )
277
+ save_snapshot(paths, fingerprint)
278
+ (paths.tmp_dir / "change_report.json").write_text(
279
+ json.dumps({"changeDetection": change_report, "generatedCode": gen_counts}, indent=2),
280
+ encoding="utf-8",
281
+ )
149
282
 
150
283
  paths.code_summary_json.write_text(json.dumps(code_summary_to_json(summary), indent=2), "utf-8")
151
284
  paths.prd_json.write_text(json.dumps(prd_to_json(prd), indent=2), encoding="utf-8")
@@ -249,6 +382,19 @@ def _blackbox_generate(config: Config) -> tuple[CodeSummary, list[PlanCase], Pat
249
382
  else "No login form found."
250
383
  ),
251
384
  )
385
+ # Retest change detection for the blackbox path (deterministic codegen, so
386
+ # code reuse is just hash bookkeeping — no LLM cost to skip).
387
+ fingerprint = build_fingerprint(summary, cases, _discovery_elements(list(discovery.pages)))
388
+ change_report = diff_fingerprint(load_snapshot(paths), fingerprint)
389
+ _meta, gen_counts = reconcile_codegen(
390
+ cases, paths, load_codegen_meta(paths), {}, "", "blackbox"
391
+ )
392
+ save_snapshot(paths, fingerprint)
393
+ (paths.tmp_dir / "change_report.json").write_text(
394
+ _json.dumps({"changeDetection": change_report, "generatedCode": gen_counts}, indent=2),
395
+ encoding="utf-8",
396
+ )
397
+
252
398
  paths.code_summary_json.write_text(
253
399
  _json.dumps(code_summary_to_json(summary), indent=2), encoding="utf-8"
254
400
  )
@@ -257,10 +403,109 @@ def _blackbox_generate(config: Config) -> tuple[CodeSummary, list[PlanCase], Pat
257
403
  return summary, cases, paths, steps
258
404
 
259
405
 
406
+ def _load_change_report(paths: Paths) -> dict[str, object]:
407
+ p = paths.tmp_dir / "change_report.json"
408
+ if not p.is_file():
409
+ return {}
410
+ try:
411
+ raw = json.loads(p.read_text(encoding="utf-8"))
412
+ return raw if isinstance(raw, dict) else {}
413
+ except ValueError:
414
+ return {}
415
+
416
+
417
+ _RUN_MODE = {
418
+ "local_only": "local_only",
419
+ "first_setup": "first_test",
420
+ "recreate_requested": "recreate",
421
+ "valid": "retest",
422
+ "repaired": "retest",
423
+ "unverified": "retest",
424
+ "missing": "blocked",
425
+ }
426
+
427
+
428
+ def _build_retest(
429
+ binding: BindingResult,
430
+ report: dict[str, object],
431
+ classifications: dict[str, str],
432
+ pub: dict[str, object],
433
+ ) -> dict[str, object]:
434
+ cd = report.get("changeDetection")
435
+ gc = report.get("generatedCode")
436
+ cd = cd if isinstance(cd, dict) else {}
437
+ gc = gc if isinstance(gc, dict) else {}
438
+ mode = _RUN_MODE.get(binding.status, "retest")
439
+ if mode == "retest" and cd.get("first"):
440
+ mode = "first_test" # binding valid but this host never generated before
441
+ kinds = sorted(set(classifications.values()))
442
+ stale_raw = pub.get("stale")
443
+ stale = stale_raw if isinstance(stale_raw, list) else []
444
+
445
+ next_actions: list[str] = []
446
+ if binding.status == "missing":
447
+ next_actions.append(
448
+ "Fix publish.projectId in suitest.config.json (see candidates), or set "
449
+ "publish.recreateProject=true / re-run with recreate_project to create a new project."
450
+ )
451
+ if stale:
452
+ next_actions.append(f"Review {len(stale)} STALE test case(s) in the TCM: {stale}")
453
+ if kinds:
454
+ next_actions.append("Inspect classified failures: " + ", ".join(kinds))
455
+ if gc.get("needs_review"):
456
+ next_actions.append(
457
+ "Code regeneration failed — prior automation kept; see codegen_meta.json (needs_review)."
458
+ )
459
+ if not next_actions:
460
+ next_actions.append("No action needed — binding resolved and results recorded.")
461
+
462
+ return {
463
+ "mode": mode,
464
+ "projectBinding": binding.to_json(),
465
+ "changeDetection": cd,
466
+ "generatedCode": gc,
467
+ "failureClassification": kinds,
468
+ "testCases": {
469
+ "created": pub.get("created", 0),
470
+ "reused": pub.get("reused", 0),
471
+ "stale": len(stale),
472
+ },
473
+ "testRun": {
474
+ "created": bool(pub.get("published")),
475
+ "runId": pub.get("runId"),
476
+ "status": pub.get("runStatus"),
477
+ },
478
+ "published": bool(pub.get("published")),
479
+ "publishReason": str(pub.get("reason", "")),
480
+ "nextActions": next_actions,
481
+ }
482
+
483
+
260
484
  def run_lifecycle(config: Config) -> LifecycleResult:
261
485
  steps: list[str] = []
262
486
  errors: list[str] = []
263
487
 
488
+ # Project binding FIRST: a stale, unrepairable binding must fail loudly
489
+ # before anything is generated, executed, or inserted server-side.
490
+ binding = resolve_binding(config, recreate=config.publish.recreate)
491
+ steps.append(f"project binding: {binding.status} ({binding.action})")
492
+ if binding.detail:
493
+ steps.append(f"binding detail: {binding.detail}")
494
+ if binding.blocks_publish:
495
+ errors.append(binding.detail)
496
+ pub: dict[str, object] = {"published": False, "reason": binding.detail, "blocked": True}
497
+ return LifecycleResult(
498
+ success=False,
499
+ summary=(
500
+ "FAILED — project binding is stale (projectId not found) and could not be "
501
+ "repaired. Nothing was generated, executed, or published."
502
+ ),
503
+ run=None,
504
+ errors=errors,
505
+ steps=steps,
506
+ retest=_build_retest(binding, {}, {}, pub),
507
+ )
508
+
264
509
  crawl_mode = _is_crawl(config) or _is_blackbox(config)
265
510
  summary_code: CodeSummary | None
266
511
  if crawl_mode:
@@ -287,10 +532,13 @@ def run_lifecycle(config: Config) -> LifecycleResult:
287
532
 
288
533
  def _finish_fail(detail: str) -> LifecycleResult:
289
534
  errors.append(f"not ready: {detail}")
535
+ kind = "dependency_not_ready" if detail.startswith("dependency") else "target_not_ready"
290
536
  run_failed = _empty_run(config, summary_code, server_started, False, detail, startup_tail)
291
537
  _finalize(config, cases, run_failed, paths)
538
+ fail_pub: dict[str, object] = {"published": False, "reason": "run aborted before tests"}
292
539
  if config.publish.enabled:
293
- steps.append(_publish_step(publish_results(config, run_failed, cases, paths)))
540
+ fail_pub = publish_results(config, run_failed, cases, paths, binding=binding)
541
+ _record_publish(fail_pub, steps, errors)
294
542
  return LifecycleResult(
295
543
  success=False,
296
544
  summary=f"FAILED — {detail}",
@@ -298,6 +546,9 @@ def run_lifecycle(config: Config) -> LifecycleResult:
298
546
  artifacts=_artifact_list(paths),
299
547
  errors=errors,
300
548
  steps=steps,
549
+ retest=_build_retest(
550
+ binding, _load_change_report(paths), {"readiness": kind}, fail_pub
551
+ ),
301
552
  )
302
553
 
303
554
  try:
@@ -392,8 +643,23 @@ def run_lifecycle(config: Config) -> LifecycleResult:
392
643
 
393
644
  run = _build_run(config, summary_code, results, server_started, ready_detail, startup_tail)
394
645
  _finalize(config, cases, run, paths)
646
+
647
+ report = _load_change_report(paths)
648
+ cd = report.get("changeDetection")
649
+ api_changed = bool(cd.get("apiChanged")) if isinstance(cd, dict) else False
650
+ classifications = classify_results(run.results, config.mode, api_changed=api_changed)
651
+ if classifications:
652
+ steps.append(
653
+ "failure classification: "
654
+ + ", ".join(f"{tid}={kind}" for tid, kind in sorted(classifications.items()))
655
+ )
656
+
657
+ pub2: dict[str, object] = {"published": False, "reason": "publish disabled"}
395
658
  if config.publish.enabled:
396
- steps.append(_publish_step(publish_results(config, run, cases, paths)))
659
+ pub2 = publish_results(
660
+ config, run, cases, paths, binding=binding, classifications=classifications
661
+ )
662
+ _record_publish(pub2, steps, errors)
397
663
 
398
664
  ok = run.failed == 0 and run.errored == 0
399
665
  verb = "PASSED" if ok else "FAILED"
@@ -407,6 +673,7 @@ def run_lifecycle(config: Config) -> LifecycleResult:
407
673
  artifacts=_artifact_list(paths),
408
674
  errors=errors,
409
675
  steps=steps,
676
+ retest=_build_retest(binding, report, classifications, pub2),
410
677
  )
411
678
 
412
679