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
@@ -193,6 +193,20 @@ def compute_and_write_run_context(
193
193
  return ctx
194
194
 
195
195
 
196
+ def refresh_run_context_snapshot(ctx: dict) -> None:
197
+ """Rewrite a run context after prepare resolves run-scoped inputs."""
198
+ payload = compact_run_context(ctx)
199
+ payload["analysis"] = {
200
+ "sourceCommit": ctx.get("ANALYSIS_SOURCE_COMMIT", ""),
201
+ "target": json.loads(ctx.get("ANALYSIS_TARGET_JSON", "{}")),
202
+ "evidenceInputs": json.loads(ctx.get("EVIDENCE_INPUTS_JSON", "[]")),
203
+ }
204
+ payload["ANALYSIS_SOURCE_COMMIT"] = ctx.get("ANALYSIS_SOURCE_COMMIT", "")
205
+ payload["ANALYSIS_TARGET_JSON"] = ctx.get("ANALYSIS_TARGET_JSON", "{}")
206
+ payload["EVIDENCE_INPUTS_JSON"] = ctx.get("EVIDENCE_INPUTS_JSON", "[]")
207
+ _atomic_write_json(Path(ctx["RUN_CONTEXT_FILE"]), payload)
208
+
209
+
196
210
  def write_run_inputs(
197
211
  *,
198
212
  project_root: Path,
@@ -207,7 +221,7 @@ def write_run_inputs(
207
221
  inputs schema (모든 키 optional):
208
222
  taskBriefPath, directive, workers, leadModel, claudeModel, codexModel,
209
223
  antigravityModel, reportWriterModel, relatedTasks, approvedPlanPath,
210
- clarificationResponsePath, renderOnly
224
+ clarificationResponsePath, analysisTarget, evidenceInputs, renderOnly
211
225
  """
212
226
  run_manifests_dir = Path(run_manifests_dir)
213
227
  path = run_manifests_dir / _run_inputs_filename(task_type_segment, seq)
@@ -227,7 +241,25 @@ def read_run_context(run_manifests_dir: Path, task_type_segment: str,
227
241
  if not path.is_file():
228
242
  return None
229
243
  payload = json.loads(path.read_text(encoding="utf-8"))
230
- return hydrate_run_context(payload)
244
+ context = hydrate_run_context(payload)
245
+ analysis = payload.get("analysis")
246
+ if isinstance(analysis, dict):
247
+ target_json = payload.get("ANALYSIS_TARGET_JSON")
248
+ if not isinstance(target_json, str):
249
+ target_json = json.dumps(analysis.get("target", {}), ensure_ascii=False)
250
+ evidence_json = payload.get("EVIDENCE_INPUTS_JSON")
251
+ if not isinstance(evidence_json, str):
252
+ evidence_json = json.dumps(
253
+ analysis.get("evidenceInputs", []), ensure_ascii=False,
254
+ )
255
+ context.update({
256
+ "ANALYSIS_SOURCE_COMMIT": str(
257
+ payload.get("ANALYSIS_SOURCE_COMMIT", analysis.get("sourceCommit", ""))
258
+ ),
259
+ "ANALYSIS_TARGET_JSON": target_json,
260
+ "EVIDENCE_INPUTS_JSON": evidence_json,
261
+ })
262
+ return context
231
263
 
232
264
 
233
265
  def read_run_inputs(run_manifests_dir: Path, task_type_segment: str,
@@ -1,9 +1,9 @@
1
1
  """Build a task-type-scoped excerpt of the final-report schema.
2
2
 
3
3
  The full schema (``schemas/final-report-v1.0.schema.json``) carries the
4
- deliverable property blocks for ALL task-types (``errorAnalysis``,
5
- ``implementationPlanning``, ``releaseHandoff``, ``implementation``,
6
- ``finalVerification``) plus a
4
+ deliverable property blocks for ALL task-types (``errorAnalysis``, the three
5
+ read-only analysis blocks, ``implementationPlanning``, ``releaseHandoff``,
6
+ ``implementation``, and ``finalVerification``) plus a
7
7
  ``$defs`` library (~38% of the file) shared across them. A single run only
8
8
  authors ONE task-type's data.json, so the report-writer worker only needs
9
9
  the common structure + its own task-type's block + the ``$defs`` those
@@ -27,15 +27,21 @@ import re
27
27
  # task-type → the per-type deliverable property key it owns. task-types
28
28
  # absent from this map (requirements-discovery,
29
29
  # improvement-discovery) have no per-type block; their excerpt keeps only
30
- # the common properties.
30
+ # the non-analysis common properties.
31
31
  _TASK_TYPE_PROPERTY = {
32
32
  "error-analysis": "errorAnalysis",
33
+ "project-analysis": "projectAnalysis",
34
+ "feature-analysis": "featureAnalysis",
35
+ "change-impact-analysis": "changeImpactAnalysis",
33
36
  "implementation-planning": "implementationPlanning",
34
37
  "release-handoff": "releaseHandoff",
35
38
  "implementation": "implementation",
36
39
  "final-verification": "finalVerification",
37
40
  }
38
41
  _ALL_PER_TYPE_PROPERTIES = frozenset(_TASK_TYPE_PROPERTY.values())
42
+ _ANALYSIS_COMMON_TASK_TYPES = frozenset(
43
+ {"project-analysis", "feature-analysis", "change-impact-analysis"}
44
+ )
39
45
 
40
46
  _REF_RE = re.compile(r'"\$ref"\s*:\s*"#/\$defs/([^"]+)"')
41
47
 
@@ -100,6 +106,8 @@ def build_schema_excerpt(schema: dict, task_type: str, cut_from_version: str = "
100
106
  """
101
107
  keep_per_type = _TASK_TYPE_PROPERTY.get(task_type)
102
108
  drop_props = _ALL_PER_TYPE_PROPERTIES - ({keep_per_type} if keep_per_type else set())
109
+ if task_type not in _ANALYSIS_COMMON_TASK_TYPES:
110
+ drop_props = drop_props | {"analysisCommon"}
103
111
 
104
112
  props = {
105
113
  k: v for k, v in schema.get("properties", {}).items() if k not in drop_props
@@ -0,0 +1,183 @@
1
+ """Self-mock signal SSOT: language-keyed regexes for detecting self-mocked tests.
2
+
3
+ Every entry is a port of one bullet from the matching
4
+ `prompts/coding-preflight/languages/<lang>.md` "Self-mock signals to refuse"
5
+ section. `doc_keyword` is the literal substring of that doc which the signal was
6
+ derived from, so the drift guard can fail when the doc and this module diverge.
7
+ Detector and guard MUST import from here; re-defining a pattern elsewhere
8
+ violates the single-reference-point rule.
9
+
10
+ Patterns stay narrow on purpose — a missed self-mock is cheaper than a false
11
+ accusation, which teaches users to ignore the gate. Narrow means matching only
12
+ the syntactic shape "stub the subject's own method, then assert the stub", plus
13
+ reaching into the subject's privates. Which object is the subject is never
14
+ inferred from naming beyond the literal `sut` token; a `<var>` capture in a
15
+ pattern exists for hit reporting, not for subject identification.
16
+
17
+ Four documented shapes are deliberately left to the mutation gate because
18
+ deciding them needs subject identity that no regex has:
19
+
20
+ - Kotlin `mockkObject(SomeSingleton)` / `mockkStatic(...)` and Java
21
+ `MockedStatic<SomeUtil>` are the anti-pattern only when the mocked singleton
22
+ *is* the unit under test, and are legitimate boundary fakes otherwise.
23
+ - Python `patch.object(Calculator, "_compute")` names a class, and a class name
24
+ alone does not say whether it is the subject or a collaborator. Only patching
25
+ the test instance itself (`patch.object(self, ...)`) is unambiguous, so that is
26
+ all `patch-sut-method` matches — `patch.object(self.service, "fetch")` patches
27
+ a collaborator reached through `self` and must stay silent.
28
+ - TypeScript `jest.spyOn(FooService.prototype, ...)` counts only when that class
29
+ is the spec file's own subject (`javascript-typescript.md:70` scopes it to
30
+ "in `foo.service.spec.ts`"). Without that scoping every prototype spy matches,
31
+ including the standard `Date.prototype` / `Repository.prototype` /
32
+ `HTMLElement.prototype` stubs of collaborators and the environment.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import posixpath
37
+ import re
38
+ from collections import namedtuple
39
+
40
+ Signal = namedtuple("Signal", "name pattern doc_keyword")
41
+
42
+
43
+ def selfmock_path_key(path: str) -> str:
44
+ """Fold one self-mock path into the spelling every consumer compares on.
45
+
46
+ Three producers name the same file and none of them agrees on spelling: the
47
+ report's §5.7.3 rows, the detector's `--test-file` arguments, and the
48
+ hand-authored waiver file. All three start from `git diff --name-only` in the
49
+ worktree cwd, so this folds only the drift that survives that shared origin —
50
+ Windows separators, a leading `./`, duplicated slashes. A single definition
51
+ is load-bearing: a waiver that normalizes differently from the coverage check
52
+ would clear one gate while tripping the other.
53
+ """
54
+ return posixpath.normpath(path.strip().replace("\\", "/"))
55
+
56
+
57
+ def waiver_entry_key(entry: dict, discriminator: str) -> tuple:
58
+ """The `(file, line, <discriminator>)` triple a waiver is matched on.
59
+
60
+ Both gates match this way — gate A on `signal`, gate B on `mutant` — so the
61
+ mechanics live here rather than being written twice. Two spellings of "is
62
+ this the same finding?" could disagree, and a waiver would clear one gate
63
+ while leaving the other failing.
64
+
65
+ The line is coerced to `int` when it can be: the waiver file is typed by
66
+ hand, and a quoted `"12"` is a JSON typo, not a different finding. Everything
67
+ else is compared as written, so a near-miss waiver fails to match and its
68
+ finding keeps failing the run.
69
+ """
70
+ line = entry.get("line")
71
+ if isinstance(line, str) and line.strip().isdigit():
72
+ line = int(line)
73
+ return (
74
+ selfmock_path_key(str(entry.get("file", ""))),
75
+ line,
76
+ entry.get(discriminator),
77
+ )
78
+
79
+
80
+ def partition_waived_entries(
81
+ findings: list[dict], waivers: list[dict], discriminator: str
82
+ ) -> tuple[list[dict], list[dict]]:
83
+ """Split findings into `(still failing, waived)` on the shared key.
84
+
85
+ A waived row carries the acknowledgement fields alongside the detector's own
86
+ spelling of the finding, so the sidecar records both what was found and who
87
+ accepted it.
88
+
89
+ Note what this does NOT do: it never inspects `reason` or `acknowledgedBy`.
90
+ Matching and adjudicating are deliberately separate — an entry with no
91
+ acknowledgement is carried through so `validate-run.py` can block on it,
92
+ instead of being dropped here where the run that produced the finding would
93
+ be excusing itself.
94
+ """
95
+ by_key = {waiver_entry_key(w, discriminator): w for w in waivers}
96
+ remaining: list[dict] = []
97
+ waived: list[dict] = []
98
+ for finding in findings:
99
+ waiver = by_key.get(waiver_entry_key(finding, discriminator))
100
+ if waiver is None:
101
+ remaining.append(finding)
102
+ else:
103
+ waived.append({**waiver, **finding})
104
+ return remaining, waived
105
+
106
+
107
+ SIGNALS: dict[str, list[Signal]] = {
108
+ "ts_js": [
109
+ # `sut` must be the whole spy target: `jest.spyOn(sut.repo, 'find')`
110
+ # stubs a collaborator reached through the subject, which is fine.
111
+ Signal("spyOn-sut",
112
+ re.compile(r"jest\.spyOn\(\s*sut\s*,[^)]*\)\.(mockReturnValue|mockResolvedValue|mockImplementation)"),
113
+ "jest.spyOn(sut"),
114
+ Signal("assign-sut-fn",
115
+ re.compile(r"\bsut\.\w+\s*=\s*(jest|vi)\.fn"),
116
+ "sut.calculateTotal = jest.fn"),
117
+ Signal("private-reach",
118
+ re.compile(r"\(\s*sut\s+as\s+any\s*\)\.\w+|sut\[['\"]\w+['\"]\]\("),
119
+ "(sut as any).privateMethod"),
120
+ ],
121
+ "python": [
122
+ # The leading \b keeps ordinary methods ending in "patch" out —
123
+ # `dispatch(self, request)` and `apply_patch(self, diff)` are not patches.
124
+ Signal("patch-sut-method",
125
+ re.compile(r"\bpatch(?:\.object)?\(\s*self\s*,"),
126
+ "unittest.mock.patch"),
127
+ # `obj` is the doc's placeholder, not a subject marker: `obj._meta` is
128
+ # everyday Django. Only the literal `sut` token identifies the subject.
129
+ Signal("private-reach",
130
+ re.compile(r"\bsut\._\w+"),
131
+ "obj._internal"),
132
+ ],
133
+ "kotlin": [
134
+ Signal("spyk-sut",
135
+ re.compile(r"\bspyk\(\s*sut\b"),
136
+ "spyk(sut)"),
137
+ # The argument list allows one level of nesting so MockK matchers
138
+ # (`any()`, `eq(1)`) inside the stubbed call still match.
139
+ Signal("every-sut-returns",
140
+ re.compile(r"\bevery\s*\{\s*sut\.\w+\((?:[^()]|\([^()]*\))*\)\s*\}\s*returns"),
141
+ "every { sut.someMethod() } returns"),
142
+ Signal("coevery-sut-returns",
143
+ re.compile(r"\bcoEvery\s*\{\s*sut\.\w+\((?:[^()]|\([^()]*\))*\)\s*\}\s*returns"),
144
+ "coEvery { sut.suspendMethod() } returns"),
145
+ Signal("private-reach",
146
+ re.compile(r"\bcallPrivateFunc\b|\bsut\.javaClass\.getDeclared(?:Method|Field)\s*\("),
147
+ "callPrivateFunc"),
148
+ ],
149
+ "rust": [
150
+ # `let sut = MockFoo::new()` makes the subject itself a generated mock.
151
+ Signal("sut-is-mock",
152
+ re.compile(r"\blet\s+(?:mut\s+)?sut\s*=\s*Mock\w+::"),
153
+ "same struct under test"),
154
+ Signal("expect-on-sut",
155
+ re.compile(r"\bsut\s*\.\s*expect_\w+\(\)"),
156
+ "expect_helper().returning"),
157
+ Signal("private-reach",
158
+ re.compile(r"#\[cfg\(test\)\]\s*pub\b"),
159
+ "#[cfg(test)] pub"),
160
+ ],
161
+ "java": [
162
+ # Both annotations must decorate the SAME field — only other annotations
163
+ # may sit between them. Two adjacent fields each carrying one of the two
164
+ # is the ordinary `@Spy` collaborator + `@InjectMocks` subject layout.
165
+ Signal("injectmocks-spy",
166
+ re.compile(r"@(?:Spy|InjectMocks)\b(?:\s*@\w+(?:\([^)]*\))?)*\s*@(?:InjectMocks|Spy)\b"),
167
+ "@InjectMocks"),
168
+ # Both branches target the subject: a spy of a collaborator and
169
+ # `doReturn(...).when(collaboratorSpy)` are the doc's "What's fine".
170
+ Signal("spy-sut-stub",
171
+ re.compile(r"\bMockito\.spy\(\s*\w*[sS]ut\b|\bdoReturn\([^;]*\)\s*\.when\(\s*\w*[sS]ut\b"),
172
+ "Mockito.spy(realSut)"),
173
+ Signal("private-reach",
174
+ re.compile(r"\bReflectionTestUtils\.(?:setField|getField|invokeMethod)\(\s*\w*[sS]ut\b"),
175
+ "ReflectionTestUtils.setField(sut"),
176
+ ],
177
+ }
178
+
179
+ EXT_TO_LANG: dict[str, str] = {
180
+ ".py": "python", ".ts": "ts_js", ".tsx": "ts_js",
181
+ ".js": "ts_js", ".jsx": "ts_js", ".mjs": "ts_js",
182
+ ".kt": "kotlin", ".kts": "kotlin", ".rs": "rust", ".java": "java",
183
+ }
@@ -3,8 +3,8 @@
3
3
  The sidecar format is documented in ``templates/reports/user-response.template.md``
4
4
  and produced byte-identically by ``report_views.serialize_user_response`` (Python)
5
5
  and ``templates/reports/report.js`` (browser). This module owns the read side of
6
- the ``## APPROVAL`` block — the implementation wizard consumes it to offer
7
- "승인 + 옵션 적용" at the approve-confirm step.
6
+ the ``## APPROVAL`` block used by the implementation wizard and the optional
7
+ ``## ANALYSIS REVIEW`` block used by analysis reruns.
8
8
  """
9
9
  from __future__ import annotations
10
10
 
@@ -12,6 +12,7 @@ import argparse
12
12
  import datetime as dt
13
13
  import json
14
14
  import re
15
+ import stat
15
16
  import sys
16
17
  from dataclasses import dataclass
17
18
  from pathlib import Path
@@ -31,6 +32,19 @@ from okstra_ctl.clarification_items import (
31
32
 
32
33
  _APPROVAL_HEADING_RE = re.compile(r"^## APPROVAL\s*$", re.MULTILINE)
33
34
  _NEXT_RESPONSE_HEADING_RE = re.compile(r"^## ", re.MULTILINE)
35
+ _ANALYSIS_REVIEW_HEADING_RE = re.compile(r"^## ANALYSIS REVIEW\s*$", re.MULTILINE)
36
+ _ANALYSIS_SIDECAR_HEADING_RE = re.compile(
37
+ r"^## (?P<filename>user-response-[^\n]+\.md)\s*$", re.MULTILINE
38
+ )
39
+ _YAML_FRONTMATTER_RE = re.compile(
40
+ r"\A---[ \t]*\r?\n(?P<body>.*?)(?:\r?\n)---[ \t]*(?:\r?\n|\Z)",
41
+ re.DOTALL,
42
+ )
43
+ _FRONTMATTER_DELIMITER_RE = re.compile(r"^---[ \t]*$", re.MULTILINE)
44
+
45
+
46
+ class UserResponseError(ValueError):
47
+ """Raised when a user-response sidecar violates its supported format."""
34
48
 
35
49
 
36
50
  @dataclass(frozen=True)
@@ -42,6 +56,296 @@ class UserResponseApprovalRecord:
42
56
  seq: str
43
57
 
44
58
 
59
+ @dataclass(frozen=True)
60
+ class AnalysisReviewRecord:
61
+ """Validated user review of an analysis report."""
62
+
63
+ status: str
64
+ affected_ids: tuple[str, ...]
65
+ reason: str
66
+ additional_evidence: str
67
+ requested_scope_change: str
68
+ task_key: str
69
+ task_type: str
70
+ source_report: str
71
+ seq: str
72
+
73
+
74
+ _ANALYSIS_REVIEW_STATUSES = frozenset({
75
+ "accepted",
76
+ "revision-requested",
77
+ "rejected",
78
+ })
79
+ _ANALYSIS_REPORT_RE = re.compile(
80
+ r"^final-report-(?P<task_type>project-analysis|feature-analysis|"
81
+ r"change-impact-analysis)-(?P<seq>\d{3})\.md$"
82
+ )
83
+
84
+
85
+ def _quoted_review_value(block: str, key: str) -> str:
86
+ match = re.search(
87
+ rf"^- {re.escape(key)}:\s*\n((?:\s*>.*\n?)+)", block, re.MULTILINE
88
+ )
89
+ if not match:
90
+ return ""
91
+ return "\n".join(
92
+ re.sub(r"^\s*>\s?", "", line)
93
+ for line in match.group(1).splitlines()
94
+ ).strip()
95
+
96
+
97
+ def _sidecar_metadata_value(sidecar_text: str, key: str) -> str:
98
+ frontmatter = _YAML_FRONTMATTER_RE.match(sidecar_text)
99
+ if frontmatter is None:
100
+ return ""
101
+ match = re.search(
102
+ rf"^{re.escape(key)}:[ \t]*(\S.*?)[ \t]*$",
103
+ frontmatter.group("body"),
104
+ re.MULTILINE,
105
+ )
106
+ return match.group(1) if match else ""
107
+
108
+
109
+ def _nearest_frontmatter_value(
110
+ sidecar_text: str, key: str, before_offset: int
111
+ ) -> str:
112
+ delimiters = [
113
+ match
114
+ for match in _FRONTMATTER_DELIMITER_RE.finditer(sidecar_text)
115
+ if match.start() < before_offset
116
+ ]
117
+ for opening, closing in reversed(list(zip(delimiters, delimiters[1:]))):
118
+ body = sidecar_text[opening.end():closing.start()]
119
+ match = re.search(
120
+ rf"^{re.escape(key)}:[ \t]*(\S.*?)[ \t]*$",
121
+ body,
122
+ re.MULTILINE,
123
+ )
124
+ if match:
125
+ return match.group(1)
126
+ return _sidecar_metadata_value(sidecar_text, key)
127
+
128
+
129
+ def _parsed_created_at(value: str) -> dt.datetime | None:
130
+ if re.fullmatch(
131
+ r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z",
132
+ value,
133
+ ) is None:
134
+ return None
135
+ try:
136
+ parsed = dt.datetime.fromisoformat(value[:-1] + "+00:00")
137
+ except ValueError:
138
+ return None
139
+ if parsed.tzinfo is None:
140
+ return None
141
+ return parsed.astimezone(dt.timezone.utc)
142
+
143
+
144
+ def _nearest_sidecar_filename(sidecar_text: str, before_offset: int) -> str:
145
+ headings = [
146
+ match
147
+ for match in _ANALYSIS_SIDECAR_HEADING_RE.finditer(sidecar_text)
148
+ if match.start() < before_offset
149
+ ]
150
+ return headings[-1].group("filename") if headings else ""
151
+
152
+
153
+ def _authoritative_analysis_review_match(
154
+ sidecar_text: str, matches: list[re.Match[str]]
155
+ ) -> re.Match[str]:
156
+ candidates: list[tuple[dt.datetime, str, int, re.Match[str]]] = []
157
+ for match in matches:
158
+ created_at = _parsed_created_at(
159
+ _nearest_frontmatter_value(sidecar_text, "created-at", match.start())
160
+ )
161
+ if created_at is None:
162
+ continue
163
+ candidates.append((
164
+ created_at,
165
+ _nearest_sidecar_filename(sidecar_text, match.start()),
166
+ match.start(),
167
+ match,
168
+ ))
169
+ if not candidates:
170
+ raise UserResponseError(
171
+ "ANALYSIS REVIEW requires a valid canonical created-at"
172
+ )
173
+ return max(candidates, key=lambda candidate: candidate[:3])[3]
174
+
175
+
176
+ def parse_analysis_review(sidecar_text: str) -> AnalysisReviewRecord | None:
177
+ """Parse the bounded ``## ANALYSIS REVIEW`` block from a sidecar.
178
+
179
+ A sidecar without this optional block is not a review. Once the block is
180
+ present, status and the fields that make a rejection actionable are
181
+ validated fail-closed.
182
+ """
183
+ matches = list(_ANALYSIS_REVIEW_HEADING_RE.finditer(sidecar_text))
184
+ if not matches:
185
+ return None
186
+ match = _authoritative_analysis_review_match(sidecar_text, matches)
187
+ block = sidecar_text[match.end():]
188
+ next_heading = _NEXT_RESPONSE_HEADING_RE.search(block)
189
+ if next_heading:
190
+ block = block[:next_heading.start()]
191
+ status = _field(block, "Status")
192
+ if status not in _ANALYSIS_REVIEW_STATUSES:
193
+ raise UserResponseError("ANALYSIS REVIEW Status is invalid")
194
+ affected = _field(block, "Affected-IDs") or ""
195
+ affected_ids = tuple(item.strip() for item in affected.split(",") if item.strip())
196
+ reason = _quoted_review_value(block, "Reason")
197
+ if status in {"revision-requested", "rejected"} and (not affected_ids or not reason):
198
+ raise UserResponseError(
199
+ f"ANALYSIS REVIEW {status} requires Affected-IDs and Reason"
200
+ )
201
+ return AnalysisReviewRecord(
202
+ status=status,
203
+ affected_ids=affected_ids,
204
+ reason=reason,
205
+ additional_evidence=_quoted_review_value(block, "Additional-Evidence"),
206
+ requested_scope_change=_quoted_review_value(block, "Requested-Scope-Change"),
207
+ task_key=_nearest_frontmatter_value(
208
+ sidecar_text, "task-key", match.start()
209
+ ),
210
+ task_type=_nearest_frontmatter_value(
211
+ sidecar_text, "task-type", match.start()
212
+ ),
213
+ source_report=_nearest_frontmatter_value(
214
+ sidecar_text, "source-report", match.start()
215
+ ),
216
+ seq=_nearest_frontmatter_value(sidecar_text, "seq", match.start()),
217
+ )
218
+
219
+
220
+ def _matching_analysis_review_sidecars(
221
+ report_path: Path,
222
+ sidecar_name: re.Pattern[str],
223
+ ) -> tuple[Path, tuple[Path, ...]]:
224
+ run_dir = report_path.parent.parent
225
+ responses_dir = run_dir / "user-responses"
226
+ try:
227
+ responses_mode = responses_dir.lstat().st_mode
228
+ except FileNotFoundError:
229
+ return responses_dir, ()
230
+ except OSError as exc:
231
+ raise UserResponseError(
232
+ f"analysis user-responses directory is unreadable under {run_dir}"
233
+ ) from exc
234
+ if not stat.S_ISDIR(responses_mode):
235
+ raise UserResponseError(
236
+ f"analysis user-responses must be a real directory under {run_dir}"
237
+ )
238
+ try:
239
+ resolved_run_dir = run_dir.resolve(strict=True)
240
+ resolved_responses_dir = responses_dir.resolve(strict=True)
241
+ except OSError as exc:
242
+ raise UserResponseError(
243
+ f"analysis user-responses directory is unreadable under {run_dir}"
244
+ ) from exc
245
+ if resolved_responses_dir != resolved_run_dir / "user-responses":
246
+ raise UserResponseError(
247
+ f"analysis user-responses must stay under {resolved_run_dir}"
248
+ )
249
+ try:
250
+ sidecars = tuple(
251
+ sorted(
252
+ (
253
+ entry
254
+ for entry in responses_dir.iterdir()
255
+ if sidecar_name.fullmatch(entry.name)
256
+ ),
257
+ key=lambda entry: entry.name,
258
+ )
259
+ )
260
+ except OSError as exc:
261
+ raise UserResponseError(
262
+ f"analysis user-responses directory is unreadable under {run_dir}"
263
+ ) from exc
264
+ return responses_dir, sidecars
265
+
266
+
267
+ def _read_analysis_review_sidecar(sidecar: Path, responses_dir: Path) -> str:
268
+ try:
269
+ sidecar_mode = sidecar.lstat().st_mode
270
+ if not stat.S_ISREG(sidecar_mode):
271
+ raise OSError("not a regular file")
272
+ resolved = sidecar.resolve(strict=True)
273
+ if resolved.parent != responses_dir.resolve(strict=True):
274
+ raise OSError("outside user-responses")
275
+ return sidecar.read_text(encoding="utf-8")
276
+ except (OSError, UnicodeError) as exc:
277
+ raise UserResponseError(
278
+ "analysis review sidecar must be a readable regular file under "
279
+ f"{responses_dir}"
280
+ ) from exc
281
+
282
+
283
+ def _analysis_review_matches_with_valid_created_at(
284
+ sidecar_text: str,
285
+ ) -> list[re.Match[str]]:
286
+ matches = list(_ANALYSIS_REVIEW_HEADING_RE.finditer(sidecar_text))
287
+ for match in matches:
288
+ created_at = _nearest_frontmatter_value(
289
+ sidecar_text, "created-at", match.start()
290
+ )
291
+ if _parsed_created_at(created_at) is None:
292
+ raise UserResponseError(
293
+ "ANALYSIS REVIEW requires a valid canonical created-at"
294
+ )
295
+ return matches
296
+
297
+
298
+ def load_authoritative_analysis_review(
299
+ report_path: Path,
300
+ *,
301
+ expected_task_key: str,
302
+ expected_task_type: str,
303
+ ) -> AnalysisReviewRecord | None:
304
+ """Load the created-at-latest review attached to one analysis report."""
305
+ report_match = _ANALYSIS_REPORT_RE.fullmatch(report_path.name)
306
+ if report_match is None:
307
+ raise UserResponseError("analysis review source is not an analysis report")
308
+ sidecar_name = re.compile(
309
+ rf"^user-response-{re.escape(report_match.group('task_type'))}-"
310
+ rf"{re.escape(report_match.group('seq'))}(?:-.+)?\.md$"
311
+ )
312
+ responses_dir, sidecars = _matching_analysis_review_sidecars(
313
+ report_path, sidecar_name
314
+ )
315
+ if not sidecars:
316
+ return None
317
+ attached: list[str] = []
318
+ for sidecar in sidecars:
319
+ text = _read_analysis_review_sidecar(sidecar, responses_dir)
320
+ if _analysis_review_matches_with_valid_created_at(text):
321
+ attached.append(f"\n## {sidecar.name}\n\n{text.strip()}\n")
322
+ if not attached:
323
+ raise UserResponseError(
324
+ "existing review sidecar has no ANALYSIS REVIEW block"
325
+ )
326
+ review = parse_analysis_review("".join(attached))
327
+ if review is None:
328
+ raise UserResponseError("analysis review sidecar is unreadable")
329
+ expected_source = (
330
+ f"runs/{report_match.group('task_type')}/reports/{report_path.name}"
331
+ )
332
+ if review.task_key != expected_task_key:
333
+ raise UserResponseError(
334
+ "analysis review task-key does not match report taskKey"
335
+ )
336
+ if review.task_type != expected_task_type:
337
+ raise UserResponseError(
338
+ "analysis review task-type does not match report taskType"
339
+ )
340
+ if review.source_report != expected_source:
341
+ raise UserResponseError(
342
+ "analysis review source-report does not match report path"
343
+ )
344
+ if review.seq != report_match.group("seq"):
345
+ raise UserResponseError("analysis review seq does not match report runSeq")
346
+ return review
347
+
348
+
45
349
  def parse_user_response_approval(
46
350
  sidecar_text: str,
47
351
  ) -> Optional[UserResponseApprovalRecord]:
@@ -76,7 +380,9 @@ _RESPONSE_HEADING_RE = re.compile(r"^## (?P<id>[A-Za-z][A-Za-z0-9]*-\d+)\s*$", r
76
380
 
77
381
 
78
382
  def _field(block: str, key: str) -> Optional[str]:
79
- m = re.search(rf"^- {re.escape(key)}:\s*(\S.*?)\s*$", block, re.MULTILINE)
383
+ m = re.search(
384
+ rf"^- {re.escape(key)}:[ \t]*(\S.*?)[ \t]*$", block, re.MULTILINE
385
+ )
80
386
  return m.group(1) if m else None
81
387
 
82
388