okstra 0.143.0 → 0.145.0

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 (46) hide show
  1. package/README.md +4 -1
  2. package/docs/architecture.md +18 -2
  3. package/docs/cli.md +39 -2
  4. package/docs/project-structure-overview.md +19 -6
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/coding-preflight/overview.md +1 -1
  8. package/runtime/prompts/lead/convergence.md +11 -3
  9. package/runtime/prompts/lead/okstra-lead-contract.md +7 -1
  10. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  11. package/runtime/prompts/profiles/_common-contract.md +1 -1
  12. package/runtime/prompts/profiles/_implementation-verifier.md +48 -2
  13. package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
  14. package/runtime/prompts/profiles/feature-analysis.md +24 -0
  15. package/runtime/prompts/profiles/forbidden-actions.json +18 -0
  16. package/runtime/prompts/profiles/project-analysis.md +24 -0
  17. package/runtime/prompts/wizard/prompts.ko.json +44 -1
  18. package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
  19. package/runtime/python/okstra_ctl/clarification_items.py +74 -1
  20. package/runtime/python/okstra_ctl/mutation_probe.py +1263 -0
  21. package/runtime/python/okstra_ctl/render.py +77 -4
  22. package/runtime/python/okstra_ctl/render_final_report.py +13 -4
  23. package/runtime/python/okstra_ctl/report_views.py +134 -3
  24. package/runtime/python/okstra_ctl/run.py +118 -0
  25. package/runtime/python/okstra_ctl/run_context.py +34 -2
  26. package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
  27. package/runtime/python/okstra_ctl/self_mock_signals.py +183 -0
  28. package/runtime/python/okstra_ctl/user_response.py +309 -3
  29. package/runtime/python/okstra_ctl/wizard.py +545 -32
  30. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
  31. package/runtime/python/okstra_ctl/workflow.py +22 -0
  32. package/runtime/schemas/final-report-v1.0.schema.json +849 -3
  33. package/runtime/skills/okstra-run/SKILL.md +13 -1
  34. package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
  35. package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
  36. package/runtime/templates/reports/final-report.template.md +220 -0
  37. package/runtime/templates/reports/i18n/en.json +8 -0
  38. package/runtime/templates/reports/i18n/ko.json +8 -0
  39. package/runtime/templates/reports/project-analysis-input.template.md +58 -0
  40. package/runtime/templates/reports/report.js +84 -5
  41. package/runtime/templates/reports/user-response.template.md +19 -1
  42. package/runtime/validators/detect_self_mock.py +220 -0
  43. package/runtime/validators/validate-report-views.py +61 -7
  44. package/runtime/validators/validate-run.py +518 -0
  45. package/runtime/validators/validate_analysis_report.py +864 -0
  46. package/src/commands/execute/render-bundle.mjs +3 -0
@@ -0,0 +1,864 @@
1
+ """Cross-field validation for structured read-only analysis reports."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from pathlib import Path, PurePosixPath
9
+
10
+ # scripts/ (repo) and python/ (installed under ~/.okstra/lib) are sibling
11
+ # source roots, so standalone validator execution must add the one that exists.
12
+ _VALIDATORS_DIR = Path(__file__).resolve().parent
13
+ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
14
+ if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
15
+ sys.path.insert(0, str(_ssot_dir))
16
+
17
+ from okstra_ctl.analysis_inputs import ANALYSIS_TASK_TYPES
18
+ from okstra_ctl.final_report_paths import final_report_data_path
19
+ from okstra_ctl.paths import RunRef
20
+ from okstra_ctl.report_views import analysis_review_context
21
+ from okstra_ctl.user_response import UserResponseError, parse_analysis_review
22
+
23
+
24
+ _ANALYSIS_BLOCKS = {
25
+ "projectAnalysis",
26
+ "featureAnalysis",
27
+ "changeImpactAnalysis",
28
+ }
29
+ _EVIDENCE_COLLECTIONS = {
30
+ "project-analysis": (
31
+ "components",
32
+ "dependencies",
33
+ "entryPoints",
34
+ "dataStores",
35
+ "externalSystems",
36
+ "featureIndex",
37
+ ),
38
+ "feature-analysis": (
39
+ "flows",
40
+ "domainRules",
41
+ "stateChanges",
42
+ "externalInteractions",
43
+ ),
44
+ "change-impact-analysis": (
45
+ "preservedBehaviors",
46
+ "impactItems",
47
+ "dependencyBlastRadius",
48
+ "testImpact",
49
+ "operationalImpact",
50
+ ),
51
+ }
52
+ _ANALYSIS_PARENT_KEYS = {
53
+ "project-analysis": "projectAnalysis",
54
+ "feature-analysis": "featureAnalysis",
55
+ "change-impact-analysis": "changeImpactAnalysis",
56
+ }
57
+ _ANALYSIS_WORKER_ROLES = {
58
+ "Claude worker",
59
+ "Codex worker",
60
+ "Antigravity worker",
61
+ }
62
+ _FINAL_ANALYSIS_REPORT_RE = re.compile(
63
+ r"^final-report-(?P<task_type>project-analysis|feature-analysis|"
64
+ r"change-impact-analysis)-(?P<seq>\d{3})\.md$"
65
+ )
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class AnalysisValidationResult:
70
+ errors: tuple[str, ...]
71
+
72
+ @property
73
+ def ok(self) -> bool:
74
+ return not self.errors
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class AnalysisTaskIdentity:
79
+ task_key: str
80
+ project_id: str
81
+ task_group: str
82
+ task_id: str
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class SourceAnalysisAuthority:
87
+ task_root: Path
88
+ source_report: Path
89
+ source_seq: str
90
+ current_identity: AnalysisTaskIdentity
91
+
92
+
93
+ def validate_analysis_snapshot(
94
+ data: dict, run_manifest: dict, errors: list[str]
95
+ ) -> None:
96
+ common = data.get("analysisCommon") or {}
97
+ if common.get("sourceCommit") != run_manifest.get("analysisSourceCommit"):
98
+ errors.append(
99
+ "analysisCommon.sourceCommit must equal run-manifest.analysisSourceCommit"
100
+ )
101
+ if common.get("evidenceInputs") != run_manifest.get("evidenceInputs"):
102
+ errors.append(
103
+ "analysisCommon.evidenceInputs must equal run-manifest.evidenceInputs "
104
+ "in the same order and with the same values"
105
+ )
106
+ scope = common.get("scope") or {}
107
+ if scope.get("resolvedTarget") != run_manifest.get("analysisTarget"):
108
+ errors.append(
109
+ "analysisCommon.scope.resolvedTarget must equal run-manifest.analysisTarget"
110
+ )
111
+
112
+
113
+ def _normalized_project_path(path: object) -> PurePosixPath | None:
114
+ if not isinstance(path, str):
115
+ return None
116
+ raw = path.strip().rstrip("/")
117
+ if not raw or raw == "." or raw.startswith("/"):
118
+ return None
119
+ if any(part in {"", ".", ".."} for part in raw.split("/")):
120
+ return None
121
+ normalized = PurePosixPath(raw)
122
+ if normalized.is_absolute():
123
+ return None
124
+ return normalized
125
+
126
+
127
+ def _path_is_included(path: object, included_paths: list[object]) -> bool:
128
+ candidate = _normalized_project_path(path)
129
+ if candidate is None:
130
+ return False
131
+ for raw in included_paths:
132
+ included = _normalized_project_path(raw)
133
+ if included is not None and (
134
+ candidate == included or included in candidate.parents
135
+ ):
136
+ return True
137
+ return False
138
+
139
+
140
+ def _validate_included_paths(included_paths: list[object], errors: list[str]) -> None:
141
+ for index, path in enumerate(included_paths):
142
+ if _normalized_project_path(path) is None:
143
+ errors.append(
144
+ f"analysisCommon.scope.includedPaths[{index}] must be a non-empty "
145
+ "project-relative path without `.` or `..` segments"
146
+ )
147
+
148
+
149
+ def _validate_evidence_rows(
150
+ parent: dict,
151
+ collection: str,
152
+ prefix: str,
153
+ included_paths: list[object],
154
+ errors: list[str],
155
+ ) -> None:
156
+ for row_index, row in enumerate(parent.get(collection) or []):
157
+ evidence = row.get("currentCodeEvidence") if isinstance(row, dict) else None
158
+ if not evidence:
159
+ errors.append(
160
+ f"{prefix}.{collection}[{row_index}].currentCodeEvidence must "
161
+ "contain current-code path and line evidence; sourceReportRefs "
162
+ "alone are insufficient"
163
+ )
164
+ continue
165
+ for evidence_index, item in enumerate(evidence):
166
+ path = item.get("path") if isinstance(item, dict) else None
167
+ if not _path_is_included(path, included_paths):
168
+ errors.append(
169
+ f"{prefix}.{collection}[{row_index}].currentCodeEvidence"
170
+ f"[{evidence_index}].path must be a normalized path inside "
171
+ "analysisCommon.scope.includedPaths"
172
+ )
173
+
174
+
175
+ def _validate_all_current_code_evidence(
176
+ task_type: str, data: dict, errors: list[str]
177
+ ) -> None:
178
+ common = data.get("analysisCommon") or {}
179
+ included_paths = ((common.get("scope") or {}).get("includedPaths") or [])
180
+ _validate_included_paths(included_paths, errors)
181
+ _validate_evidence_rows(
182
+ common, "confirmedFacts", "analysisCommon", included_paths, errors
183
+ )
184
+ parent_key = _ANALYSIS_PARENT_KEYS[task_type]
185
+ parent = data.get(parent_key) or {}
186
+ for collection in _EVIDENCE_COLLECTIONS[task_type]:
187
+ _validate_evidence_rows(
188
+ parent, collection, parent_key, included_paths, errors
189
+ )
190
+ feature = (((common.get("scope") or {}).get("resolvedTarget") or {}).get(
191
+ "feature"
192
+ ) or {})
193
+ if feature:
194
+ _validate_evidence_rows(
195
+ {"feature": [feature]},
196
+ "feature",
197
+ "analysisCommon.scope.resolvedTarget",
198
+ included_paths,
199
+ errors,
200
+ )
201
+
202
+
203
+ def _validate_project_semantics(data: dict, errors: list[str]) -> None:
204
+ common = data.get("analysisCommon") or {}
205
+ included = ((common.get("scope") or {}).get("includedPaths") or [])
206
+ analysis = data.get("projectAnalysis") or {}
207
+ for row in analysis.get("featureIndex") or []:
208
+ if not isinstance(row, dict):
209
+ continue
210
+ entry = row.get("representativeEntryPoint") or {}
211
+ if not _path_is_included(entry.get("path"), included):
212
+ errors.append(
213
+ f"projectAnalysis.featureIndex `{row.get('id') or '?'}` representative "
214
+ "entry point must be inside analysisCommon.scope.includedPaths"
215
+ )
216
+
217
+
218
+ def _validate_feature_semantics(data: dict, errors: list[str]) -> None:
219
+ analysis = data.get("featureAnalysis") or {}
220
+ common_target = (((data.get("analysisCommon") or {}).get("scope") or {}).get(
221
+ "resolvedTarget"
222
+ ) or {})
223
+ feature_target = analysis.get("target") or {}
224
+ for field in ("inputMode", "requestedValue"):
225
+ if feature_target.get(field) != common_target.get(field):
226
+ errors.append(
227
+ f"featureAnalysis.target.{field} must match "
228
+ f"analysisCommon.scope.resolvedTarget.{field}"
229
+ )
230
+ resolved_feature = common_target.get("feature") or {}
231
+ if common_target.get("inputMode") == "feature-index" and (
232
+ feature_target.get("featureId") != resolved_feature.get("id")
233
+ ):
234
+ errors.append(
235
+ "featureAnalysis.target.featureId must match the resolved feature id"
236
+ )
237
+
238
+
239
+ def _validate_change_impact_semantics(data: dict, errors: list[str]) -> None:
240
+ analysis = data.get("changeImpactAnalysis") or {}
241
+ allowed = {"constraint", "unknown"}
242
+ for index, row in enumerate(analysis.get("planningInputs") or []):
243
+ if not isinstance(row, dict):
244
+ continue
245
+ for field in sorted(set(row) - allowed):
246
+ errors.append(
247
+ f"changeImpactAnalysis.planningInputs[{index}] forbids `{field}`; "
248
+ "planning inputs may contain constraints and unknowns only"
249
+ )
250
+
251
+
252
+ def _required_worker_roles(
253
+ run_manifest: dict, errors: list[str]
254
+ ) -> tuple[str, ...]:
255
+ contract = run_manifest.get("teamContract")
256
+ entries = (
257
+ contract.get("requiredWorkerRoles")
258
+ if isinstance(contract, dict)
259
+ else None
260
+ )
261
+ if not isinstance(entries, list) or not entries:
262
+ errors.append(
263
+ "run-manifest.teamContract.requiredWorkerRoles must be a non-empty "
264
+ "array of worker contract objects"
265
+ )
266
+ return ()
267
+ roles: list[str] = []
268
+ for index, entry in enumerate(entries):
269
+ role = entry.get("role") if isinstance(entry, dict) else None
270
+ if (
271
+ not isinstance(role, str)
272
+ or not role.strip()
273
+ or role != role.strip()
274
+ ):
275
+ errors.append(
276
+ "run-manifest.teamContract.requiredWorkerRoles"
277
+ f"[{index}].role must be an exact non-empty string"
278
+ )
279
+ return ()
280
+ if role in roles:
281
+ errors.append(
282
+ "run-manifest.teamContract.requiredWorkerRoles must not contain "
283
+ f"duplicate role `{role}`"
284
+ )
285
+ return ()
286
+ roles.append(role)
287
+ return tuple(roles)
288
+
289
+
290
+ def _required_worker_rows(data: dict, required_roles: tuple[str, ...]) -> list[dict]:
291
+ required = set(required_roles)
292
+ return [
293
+ row
294
+ for row in data.get("executionStatus") or []
295
+ if isinstance(row, dict) and row.get("role") in required
296
+ ]
297
+
298
+
299
+ def _scope_confirmation_status(
300
+ run_manifest: dict, errors: list[str]
301
+ ) -> str | None:
302
+ snapshot = run_manifest.get("analysisScopeConfirmation")
303
+ if not isinstance(snapshot, dict):
304
+ errors.append(
305
+ "run-manifest.analysisScopeConfirmation must be a pre-dispatch "
306
+ "brief confirmation snapshot"
307
+ )
308
+ return None
309
+ task_brief_path = snapshot.get("taskBriefPath")
310
+ status = snapshot.get("status")
311
+ brief_sha256 = snapshot.get("briefSha256")
312
+ valid = True
313
+ if (
314
+ not isinstance(task_brief_path, str)
315
+ or not task_brief_path
316
+ or task_brief_path != run_manifest.get("taskBriefPath")
317
+ ):
318
+ errors.append(
319
+ "run-manifest.analysisScopeConfirmation.taskBriefPath must equal "
320
+ "run-manifest.taskBriefPath"
321
+ )
322
+ valid = False
323
+ if status not in {"complete", "partial", "pending", "skipped"}:
324
+ errors.append(
325
+ "run-manifest.analysisScopeConfirmation.status must be one of "
326
+ "complete, partial, pending, skipped"
327
+ )
328
+ valid = False
329
+ if not isinstance(brief_sha256, str) or re.fullmatch(
330
+ r"[0-9a-f]{64}", brief_sha256
331
+ ) is None:
332
+ errors.append(
333
+ "run-manifest.analysisScopeConfirmation.briefSha256 must be a "
334
+ "lowercase SHA-256 hex digest"
335
+ )
336
+ valid = False
337
+ return status if valid else None
338
+
339
+
340
+ def _validate_analysis_verdict(
341
+ data: dict,
342
+ required_roles: tuple[str, ...],
343
+ reporter_confirmation: str | None,
344
+ errors: list[str],
345
+ ) -> None:
346
+ common = data.get("analysisCommon") or {}
347
+ scope = common.get("scope") or {}
348
+ worker_rows = _required_worker_rows(data, required_roles)
349
+ worker_blocked = not any(row.get("status") == "completed" for row in worker_rows)
350
+ scope_blocked = reporter_confirmation != "complete"
351
+ unresolved_review = any(
352
+ isinstance(row, dict) and row.get("outcome") == "still-unresolved"
353
+ for row in common.get("analysisReviewResolution") or []
354
+ )
355
+ partial = unresolved_review or bool(scope.get("unscannedPaths")) or any(
356
+ isinstance(row, dict) and row.get("affectsVerdict") is True
357
+ for row in common.get("unknowns") or []
358
+ )
359
+ expected = "blocked" if worker_blocked or scope_blocked else (
360
+ "analysis-partial" if partial else "analysis-complete"
361
+ )
362
+ actual = {
363
+ str((data.get("verdictCard") or {}).get("verdictToken") or ""),
364
+ str((data.get("finalVerdict") or {}).get("verdictToken") or ""),
365
+ }
366
+ if actual == {expected}:
367
+ return
368
+ reasons = []
369
+ if worker_blocked:
370
+ reasons.append("required analysis workers produced zero completed results")
371
+ if scope_blocked:
372
+ reasons.append("brief frontmatter reporter-confirmations is not complete")
373
+ if partial:
374
+ reasons.append(
375
+ "a still-unresolved review resolution, unscannedPaths, or an "
376
+ "affectsVerdict unknown remains"
377
+ )
378
+ detail = "; ".join(reasons) or "the analysis scope is fully scanned"
379
+ errors.append(f"verdict must be `{expected}` because {detail}")
380
+
381
+
382
+ def _validate_scope_before_dispatch(
383
+ data: dict,
384
+ required_roles: tuple[str, ...],
385
+ reporter_confirmation: str | None,
386
+ errors: list[str],
387
+ ) -> None:
388
+ if reporter_confirmation == "complete":
389
+ return
390
+ dispatched = any(
391
+ row.get("status") != "not-run"
392
+ for row in _required_worker_rows(data, required_roles)
393
+ )
394
+ if dispatched:
395
+ errors.append(
396
+ "required analysis workers were dispatched before "
397
+ "reporter-confirmations: complete was recorded"
398
+ )
399
+
400
+
401
+ def validate_analysis_semantics(
402
+ task_type: str,
403
+ data: dict,
404
+ run_manifest: dict,
405
+ reporter_confirmation: str | None,
406
+ errors: list[str],
407
+ ) -> None:
408
+ required_roles = _required_worker_roles(run_manifest, errors)
409
+ analysis_worker_roles = tuple(
410
+ role for role in required_roles if role in _ANALYSIS_WORKER_ROLES
411
+ )
412
+ _validate_all_current_code_evidence(task_type, data, errors)
413
+ if task_type == "project-analysis":
414
+ _validate_project_semantics(data, errors)
415
+ elif task_type == "feature-analysis":
416
+ _validate_feature_semantics(data, errors)
417
+ elif task_type == "change-impact-analysis":
418
+ _validate_change_impact_semantics(data, errors)
419
+ _validate_scope_before_dispatch(
420
+ data, analysis_worker_roles, reporter_confirmation, errors
421
+ )
422
+ _validate_analysis_verdict(
423
+ data, analysis_worker_roles, reporter_confirmation, errors
424
+ )
425
+
426
+
427
+ def _actual_analysis_task_type(
428
+ data: dict, run_manifest: dict, errors: list[str]
429
+ ) -> str:
430
+ header = data.get("header")
431
+ reported = str(header.get("taskType") or "") if isinstance(header, dict) else ""
432
+ actual = str(run_manifest.get("taskType") or "")
433
+ if actual and reported != actual:
434
+ errors.append(
435
+ "header.taskType must equal run-manifest.taskType "
436
+ f"(`{reported}` != `{actual}`)"
437
+ )
438
+ present_blocks = sorted(_ANALYSIS_BLOCKS.intersection(data))
439
+ if actual not in ANALYSIS_TASK_TYPES and present_blocks:
440
+ errors.append(
441
+ "non-analysis run must not contain analysis-only blocks: "
442
+ + ", ".join(present_blocks)
443
+ )
444
+ return actual
445
+
446
+
447
+ def _analysis_task_root(
448
+ report_path: Path, project_root: Path, errors: list[str]
449
+ ) -> Path | None:
450
+ try:
451
+ run_ref = RunRef.from_report_path(report_path)
452
+ except ValueError:
453
+ errors.append(
454
+ "current analysis report path cannot resolve its task root"
455
+ )
456
+ return None
457
+ if run_ref.stage is not None:
458
+ errors.append(
459
+ "current analysis report path cannot resolve its task root"
460
+ )
461
+ return None
462
+ if report_path.parent.resolve() != run_ref.reports_dir.resolve():
463
+ errors.append(
464
+ "current analysis report path cannot resolve its task root"
465
+ )
466
+ return None
467
+ task_root = run_ref.task_root.resolve()
468
+ try:
469
+ task_root.relative_to(project_root.resolve())
470
+ except ValueError:
471
+ errors.append("current analysis report task root escapes the project root")
472
+ return None
473
+ return task_root
474
+
475
+
476
+ def _contained_source_report(
477
+ source_report: str,
478
+ task_root: Path,
479
+ errors: list[str],
480
+ ) -> Path | None:
481
+ if not source_report:
482
+ errors.append("ANALYSIS REVIEW source-report is required")
483
+ return None
484
+ relative = Path(source_report)
485
+ if relative.is_absolute():
486
+ errors.append("ANALYSIS REVIEW source-report must be task-relative")
487
+ return None
488
+ if ".." in PurePosixPath(source_report).parts:
489
+ errors.append("ANALYSIS REVIEW source-report must not contain `..`")
490
+ return None
491
+ resolved = (task_root / relative).resolve()
492
+ try:
493
+ resolved.relative_to(task_root)
494
+ except ValueError:
495
+ errors.append("ANALYSIS REVIEW source-report escapes the task root")
496
+ return None
497
+ return resolved
498
+
499
+
500
+ def _current_analysis_task_identity(
501
+ data: dict,
502
+ task_root: Path,
503
+ run_manifest_task_key: str,
504
+ errors: list[str],
505
+ ) -> AnalysisTaskIdentity | None:
506
+ header = data.get("header")
507
+ frontmatter = data.get("frontmatter")
508
+ if not isinstance(header, dict) or not isinstance(frontmatter, dict):
509
+ errors.append("current analysis data must define its task identity")
510
+ return None
511
+ task_key = header.get("taskKey")
512
+ project_id = frontmatter.get("projectId")
513
+ task_group = frontmatter.get("taskGroup")
514
+ task_id = frontmatter.get("taskId")
515
+ values = (task_key, project_id, task_group, task_id, run_manifest_task_key)
516
+ if any(not isinstance(value, str) or not value for value in values):
517
+ errors.append("current analysis task identity must be complete")
518
+ return None
519
+ identity = AnalysisTaskIdentity(
520
+ task_key=task_key,
521
+ project_id=project_id,
522
+ task_group=task_group,
523
+ task_id=task_id,
524
+ )
525
+ if identity.task_key != run_manifest_task_key:
526
+ errors.append("current header.taskKey must equal run-manifest.taskKey")
527
+ if identity.task_key != (
528
+ f"{identity.project_id}:{identity.task_group}:{identity.task_id}"
529
+ ):
530
+ errors.append("current report task identity fields must equal header.taskKey")
531
+ if (identity.task_group, identity.task_id) != (
532
+ task_root.parent.name,
533
+ task_root.name,
534
+ ):
535
+ errors.append("current report task identity must match its task root")
536
+ return identity
537
+
538
+
539
+ def _validate_source_analysis_task_identity(
540
+ source_header: dict,
541
+ source_frontmatter: dict,
542
+ current: AnalysisTaskIdentity,
543
+ errors: list[str],
544
+ ) -> None:
545
+ expected_fields = (
546
+ (source_header, "taskKey", current.task_key, "header.taskKey"),
547
+ (
548
+ source_frontmatter,
549
+ "projectId",
550
+ current.project_id,
551
+ "frontmatter.projectId",
552
+ ),
553
+ (
554
+ source_frontmatter,
555
+ "taskGroup",
556
+ current.task_group,
557
+ "frontmatter.taskGroup",
558
+ ),
559
+ (source_frontmatter, "taskId", current.task_id, "frontmatter.taskId"),
560
+ )
561
+ for block, key, expected, label in expected_fields:
562
+ if block.get(key) != expected:
563
+ errors.append(
564
+ f"ANALYSIS REVIEW source {label} must match current task identity"
565
+ )
566
+
567
+
568
+ def _validate_source_analysis_lineage(
569
+ task_root: Path,
570
+ source: Path,
571
+ current_match: re.Match[str],
572
+ source_match: re.Match[str],
573
+ data: dict,
574
+ current_task_type: str,
575
+ review_seq: str,
576
+ report_path: Path,
577
+ errors: list[str],
578
+ ) -> None:
579
+ current_seq = current_match.group("seq")
580
+ source_seq = source_match.group("seq")
581
+ current_common = data.get("analysisCommon")
582
+ if current_match.group("task_type") != current_task_type:
583
+ errors.append(
584
+ "current analysis report filename task type must equal the current task type"
585
+ )
586
+ if not isinstance(current_common, dict) or current_common.get("runSeq") != current_seq:
587
+ errors.append(
588
+ "current analysisCommon.runSeq must match the current report filename"
589
+ )
590
+ expected_parent = RunRef.from_task_root(
591
+ task_root, current_task_type
592
+ ).reports_dir.resolve()
593
+ if source.parent != expected_parent:
594
+ errors.append(
595
+ "ANALYSIS REVIEW source report must belong to the current task and analysis type"
596
+ )
597
+ if source_match.group("task_type") != current_task_type:
598
+ errors.append(
599
+ "ANALYSIS REVIEW source analysis task type must match the current analysis task type"
600
+ )
601
+ if review_seq != source_seq:
602
+ errors.append("ANALYSIS REVIEW review seq must match the source report seq")
603
+ if source == report_path.resolve() or int(source_seq) >= int(current_seq):
604
+ errors.append("ANALYSIS REVIEW source report must predate the current report")
605
+
606
+
607
+ def _source_analysis_authority(
608
+ source_report: str,
609
+ review_seq: str,
610
+ data: dict,
611
+ current_task_type: str,
612
+ current_task_key: str,
613
+ report_path: Path,
614
+ project_root: Path,
615
+ errors: list[str],
616
+ ) -> SourceAnalysisAuthority | None:
617
+ initial_error_count = len(errors)
618
+ task_root = _analysis_task_root(report_path, project_root, errors)
619
+ if task_root is None:
620
+ return None
621
+ source = _contained_source_report(source_report, task_root, errors)
622
+ if source is None:
623
+ return None
624
+ current_match = _FINAL_ANALYSIS_REPORT_RE.fullmatch(report_path.name)
625
+ source_match = _FINAL_ANALYSIS_REPORT_RE.fullmatch(source.name)
626
+ if current_match is None:
627
+ errors.append("current analysis report filename is invalid")
628
+ return None
629
+ if source_match is None:
630
+ errors.append("ANALYSIS REVIEW source report filename is invalid")
631
+ return None
632
+ current_identity = _current_analysis_task_identity(
633
+ data, task_root, current_task_key, errors
634
+ )
635
+ if current_identity is None:
636
+ return None
637
+ _validate_source_analysis_lineage(
638
+ task_root,
639
+ source,
640
+ current_match,
641
+ source_match,
642
+ data,
643
+ current_task_type,
644
+ review_seq,
645
+ report_path,
646
+ errors,
647
+ )
648
+ if len(errors) != initial_error_count:
649
+ return None
650
+ return SourceAnalysisAuthority(
651
+ task_root=task_root,
652
+ source_report=source,
653
+ source_seq=source_match.group("seq"),
654
+ current_identity=current_identity,
655
+ )
656
+
657
+
658
+ def _load_source_analysis_payload(
659
+ authority: SourceAnalysisAuthority,
660
+ errors: list[str],
661
+ ) -> dict | None:
662
+ source_data = final_report_data_path(authority.source_report).resolve()
663
+ try:
664
+ source_data.relative_to(authority.task_root)
665
+ except ValueError:
666
+ errors.append("ANALYSIS REVIEW source data escapes the task root")
667
+ return None
668
+ if not authority.source_report.is_file() or not source_data.is_file():
669
+ errors.append("ANALYSIS REVIEW source report has no analysis data")
670
+ return None
671
+ try:
672
+ source_payload = json.loads(source_data.read_text(encoding="utf-8"))
673
+ except (json.JSONDecodeError, OSError):
674
+ errors.append("ANALYSIS REVIEW source report has no analysis data")
675
+ return None
676
+ if not isinstance(source_payload, dict):
677
+ errors.append("ANALYSIS REVIEW source report has no analysis data")
678
+ return None
679
+ blocks = (
680
+ source_payload.get("header"),
681
+ source_payload.get("frontmatter"),
682
+ source_payload.get("analysisCommon"),
683
+ )
684
+ if not all(
685
+ isinstance(block, dict) for block in blocks
686
+ ):
687
+ errors.append("ANALYSIS REVIEW source report has no analysis data")
688
+ return None
689
+ return source_payload
690
+
691
+
692
+ def _source_analysis_payload_matches_authority(
693
+ source_payload: dict,
694
+ authority: SourceAnalysisAuthority,
695
+ current_task_type: str,
696
+ errors: list[str],
697
+ ) -> bool:
698
+ initial_error_count = len(errors)
699
+ source_header = source_payload["header"]
700
+ source_frontmatter = source_payload["frontmatter"]
701
+ source_common = source_payload["analysisCommon"]
702
+ source_types = (
703
+ source_header.get("taskType"),
704
+ source_frontmatter.get("taskType"),
705
+ )
706
+ _validate_source_analysis_task_identity(
707
+ source_header,
708
+ source_frontmatter,
709
+ authority.current_identity,
710
+ errors,
711
+ )
712
+ if any(source_type not in ANALYSIS_TASK_TYPES for source_type in source_types):
713
+ errors.append(
714
+ "ANALYSIS REVIEW source analysis task type must belong to ANALYSIS_TASK_TYPES"
715
+ )
716
+ if any(source_type != current_task_type for source_type in source_types):
717
+ errors.append(
718
+ "ANALYSIS REVIEW source analysis task type must match the current analysis task type"
719
+ )
720
+ if source_common.get("runSeq") != authority.source_seq:
721
+ errors.append(
722
+ "ANALYSIS REVIEW source analysisCommon.runSeq must match the source report seq"
723
+ )
724
+ return len(errors) == initial_error_count
725
+
726
+
727
+ def _source_analysis_selector_ids(
728
+ source_report: str,
729
+ review_seq: str,
730
+ data: dict,
731
+ current_task_type: str,
732
+ current_task_key: str,
733
+ report_path: Path,
734
+ project_root: Path,
735
+ errors: list[str],
736
+ ) -> set[str] | None:
737
+ authority = _source_analysis_authority(
738
+ source_report,
739
+ review_seq,
740
+ data,
741
+ current_task_type,
742
+ current_task_key,
743
+ report_path,
744
+ project_root,
745
+ errors,
746
+ )
747
+ if authority is None:
748
+ return None
749
+ source_payload = _load_source_analysis_payload(authority, errors)
750
+ if source_payload is None:
751
+ return None
752
+ if not _source_analysis_payload_matches_authority(
753
+ source_payload, authority, current_task_type, errors
754
+ ):
755
+ return None
756
+ context = analysis_review_context(authority.source_report)
757
+ if context is None:
758
+ errors.append("ANALYSIS REVIEW source report has no analysis data")
759
+ return None
760
+ return set(context.selector_ids)
761
+
762
+
763
+ def validate_analysis_review_resolution(
764
+ data: dict,
765
+ clarification_text: str,
766
+ errors: list[str],
767
+ *,
768
+ report_path: Path | None = None,
769
+ project_root: Path | None = None,
770
+ current_task_type: str = "",
771
+ current_task_key: str = "",
772
+ ) -> None:
773
+ resolutions = (data.get("analysisCommon") or {}).get(
774
+ "analysisReviewResolution"
775
+ ) or []
776
+ resolution_rows = [row for row in resolutions if isinstance(row, dict)]
777
+ by_id = {str(row.get("affectedId") or ""): row for row in resolution_rows}
778
+ confirmed_ids = {
779
+ str(row.get("id") or "")
780
+ for row in (data.get("analysisCommon") or {}).get("confirmedFacts") or []
781
+ if isinstance(row, dict)
782
+ }
783
+ for affected_id, row in by_id.items():
784
+ if row.get("outcome") == "still-unresolved" and affected_id in confirmed_ids:
785
+ errors.append(
786
+ f"`{affected_id}` remains refuted but is still present in confirmedFacts"
787
+ )
788
+ try:
789
+ review = parse_analysis_review(clarification_text) if clarification_text else None
790
+ except UserResponseError as exc:
791
+ errors.append(f"analysis review input is invalid: {exc}")
792
+ return
793
+ if review is None:
794
+ if resolutions:
795
+ errors.append(
796
+ "analysisReviewResolution must be empty when no prior analysis review exists"
797
+ )
798
+ return
799
+ reviewed_ids = list(review.affected_ids)
800
+ resolution_ids = [str(row.get("affectedId") or "") for row in resolution_rows]
801
+ for affected_id in sorted(set(reviewed_ids)):
802
+ if reviewed_ids.count(affected_id) > 1:
803
+ errors.append(f"ANALYSIS REVIEW has duplicate Affected-IDs `{affected_id}`")
804
+ for affected_id in sorted(set(resolution_ids)):
805
+ if resolution_ids.count(affected_id) > 1:
806
+ errors.append(
807
+ f"analysisReviewResolution has duplicate reviewed id `{affected_id}`"
808
+ )
809
+ reviewed_set = set(reviewed_ids)
810
+ resolution_set = set(resolution_ids)
811
+ if report_path is not None and project_root is not None:
812
+ source_ids = _source_analysis_selector_ids(
813
+ review.source_report,
814
+ review.seq,
815
+ data,
816
+ current_task_type,
817
+ current_task_key,
818
+ report_path,
819
+ project_root,
820
+ errors,
821
+ )
822
+ if source_ids is not None:
823
+ for affected_id in sorted(reviewed_set - source_ids):
824
+ errors.append(
825
+ f"ANALYSIS REVIEW Affected-ID `{affected_id}` is not present "
826
+ "in source analysis data"
827
+ )
828
+ for affected_id in sorted(reviewed_set - resolution_set):
829
+ errors.append(
830
+ f"analysisReviewResolution is missing reviewed id `{affected_id}`"
831
+ )
832
+ for affected_id in sorted(resolution_set - reviewed_set):
833
+ errors.append(
834
+ f"analysisReviewResolution has unknown reviewed id `{affected_id}`"
835
+ )
836
+
837
+
838
+ def validate_analysis_report(
839
+ *,
840
+ data: dict,
841
+ report_path: Path,
842
+ project_root: Path,
843
+ run_manifest: dict,
844
+ clarification_text: str,
845
+ ) -> AnalysisValidationResult:
846
+ errors: list[str] = []
847
+ task_type = _actual_analysis_task_type(data, run_manifest, errors)
848
+ if task_type not in ANALYSIS_TASK_TYPES:
849
+ return AnalysisValidationResult(errors=tuple(errors))
850
+ reporter_confirmation = _scope_confirmation_status(run_manifest, errors)
851
+ validate_analysis_snapshot(data, run_manifest, errors)
852
+ validate_analysis_semantics(
853
+ task_type, data, run_manifest, reporter_confirmation, errors
854
+ )
855
+ validate_analysis_review_resolution(
856
+ data,
857
+ clarification_text,
858
+ errors,
859
+ report_path=report_path,
860
+ project_root=project_root,
861
+ current_task_type=task_type,
862
+ current_task_key=str(run_manifest.get("taskKey") or ""),
863
+ )
864
+ return AnalysisValidationResult(errors=tuple(errors))