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
|
@@ -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
|
-
|
|
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``,
|
|
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
|
|
7
|
-
|
|
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(
|
|
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
|
|