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.
- package/README.md +4 -1
- package/docs/architecture.md +18 -2
- package/docs/cli.md +39 -2
- package/docs/project-structure-overview.md +19 -6
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/coding-preflight/overview.md +1 -1
- package/runtime/prompts/lead/convergence.md +11 -3
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -1
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +48 -2
- package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
- package/runtime/prompts/profiles/feature-analysis.md +24 -0
- package/runtime/prompts/profiles/forbidden-actions.json +18 -0
- package/runtime/prompts/profiles/project-analysis.md +24 -0
- package/runtime/prompts/wizard/prompts.ko.json +44 -1
- package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
- package/runtime/python/okstra_ctl/clarification_items.py +74 -1
- package/runtime/python/okstra_ctl/mutation_probe.py +1263 -0
- package/runtime/python/okstra_ctl/render.py +77 -4
- package/runtime/python/okstra_ctl/render_final_report.py +13 -4
- package/runtime/python/okstra_ctl/report_views.py +134 -3
- package/runtime/python/okstra_ctl/run.py +118 -0
- package/runtime/python/okstra_ctl/run_context.py +34 -2
- package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
- package/runtime/python/okstra_ctl/self_mock_signals.py +183 -0
- package/runtime/python/okstra_ctl/user_response.py +309 -3
- package/runtime/python/okstra_ctl/wizard.py +545 -32
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
- package/runtime/python/okstra_ctl/workflow.py +22 -0
- package/runtime/schemas/final-report-v1.0.schema.json +849 -3
- package/runtime/skills/okstra-run/SKILL.md +13 -1
- package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
- package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
- package/runtime/templates/reports/final-report.template.md +220 -0
- package/runtime/templates/reports/i18n/en.json +8 -0
- package/runtime/templates/reports/i18n/ko.json +8 -0
- package/runtime/templates/reports/project-analysis-input.template.md +58 -0
- package/runtime/templates/reports/report.js +84 -5
- package/runtime/templates/reports/user-response.template.md +19 -1
- package/runtime/validators/detect_self_mock.py +220 -0
- package/runtime/validators/validate-report-views.py +61 -7
- package/runtime/validators/validate-run.py +518 -0
- package/runtime/validators/validate_analysis_report.py +864 -0
- 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))
|