okstra 0.148.1 → 0.149.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/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-render-report-views.py +20 -0
- package/runtime/prompts/profiles/project-analysis.md +18 -0
- package/runtime/prompts/profiles/release-handoff.md +3 -0
- package/runtime/prompts/profiles/requirements-discovery.md +7 -0
- package/runtime/python/okstra_ctl/report_html/common.py +77 -47
- package/runtime/python/okstra_ctl/report_html/filters.py +125 -30
- package/runtime/python/okstra_ctl/report_html/models.py +15 -0
- package/runtime/python/okstra_ctl/report_html/render.py +44 -5
- package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +14 -4
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +3 -3
- package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +5 -3
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -7
- package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +5 -9
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +2 -5
- package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +1 -2
- package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +90 -4
- package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +1 -8
- package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +6 -4
- package/runtime/python/okstra_ctl/report_html/visualizations.py +146 -11
- package/runtime/python/okstra_ctl/report_view_artifacts.py +5 -0
- package/runtime/python/okstra_ctl/time_report.py +2 -2
- package/runtime/python/okstra_ctl/usage_report.py +2 -2
- package/runtime/schemas/final-report-v2.0.schema.json +229 -1
- package/runtime/templates/reports/final-report.template.md +55 -0
- package/runtime/templates/reports/html/assets/base.css +59 -9
- package/runtime/templates/reports/html/base.template.html +21 -42
- package/runtime/templates/reports/html/macros/forms.html +20 -20
- package/runtime/templates/reports/html/macros/layout.html +18 -5
- package/runtime/templates/reports/html/macros/visualizations.html +7 -5
- package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +30 -15
- package/runtime/templates/reports/html/tasks/error-analysis.template.html +22 -15
- package/runtime/templates/reports/html/tasks/feature-analysis.template.html +35 -15
- package/runtime/templates/reports/html/tasks/final-verification.template.html +21 -14
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +78 -19
- package/runtime/templates/reports/html/tasks/implementation.template.html +34 -16
- package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +11 -11
- package/runtime/templates/reports/html/tasks/project-analysis.template.html +65 -25
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +28 -14
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +35 -15
- package/runtime/validators/validate_analysis_report.py +36 -0
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -138,9 +138,29 @@ def _v2_run_meta(data: dict, markdown_path: Path, args: argparse.Namespace):
|
|
|
138
138
|
task_type,
|
|
139
139
|
seq,
|
|
140
140
|
args.source_report or markdown_path.name,
|
|
141
|
+
_elapsed_ms(markdown_path, task_type, seq),
|
|
141
142
|
)
|
|
142
143
|
|
|
143
144
|
|
|
145
|
+
def _elapsed_ms(markdown_path, task_type: str, seq: str) -> int | None:
|
|
146
|
+
"""Wall-clock milliseconds for this run, or None when it cannot be measured.
|
|
147
|
+
|
|
148
|
+
The measurement lives in the run's team-state, not in the report data, so
|
|
149
|
+
the CLI resolves it here rather than teaching the renderer about run
|
|
150
|
+
layout. A missing or timestamp-less state yields None — the header then
|
|
151
|
+
omits the field instead of printing a zero.
|
|
152
|
+
"""
|
|
153
|
+
from okstra_ctl.report_view_artifacts import team_state_path_for_report
|
|
154
|
+
from okstra_ctl.time_report import wall_clock_ms
|
|
155
|
+
|
|
156
|
+
state_path = team_state_path_for_report(markdown_path, task_type, seq)
|
|
157
|
+
try:
|
|
158
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
159
|
+
except (OSError, ValueError):
|
|
160
|
+
return None
|
|
161
|
+
return wall_clock_ms(state) or None if isinstance(state, dict) else None
|
|
162
|
+
|
|
163
|
+
|
|
144
164
|
def main(argv: list[str] | None = None) -> int:
|
|
145
165
|
parser = argparse.ArgumentParser(
|
|
146
166
|
description="Render the self-contained HTML view of an okstra final-report."
|
|
@@ -18,6 +18,24 @@
|
|
|
18
18
|
- scan scope and excluded areas
|
|
19
19
|
- components, dependency directions, entry points, repositories, and external integrations
|
|
20
20
|
- shallow feature index and unresolved navigation questions
|
|
21
|
+
- Fill these when the repository states them; leave a block out rather than guessing at it:
|
|
22
|
+
- `projectAnalysis.techStack` — the languages the project is written in with their
|
|
23
|
+
versions, the frameworks it runs on, and the package manager, build, test, lint,
|
|
24
|
+
typecheck, format, CI and container tools. Record where each version was read via
|
|
25
|
+
`versionSource`: a manifest range (`^1.17.0`) and a lockfile pin are different facts,
|
|
26
|
+
and a mismatch between a pinned server image and a ranged client is a real
|
|
27
|
+
compatibility risk that collapses if both are written the same way. A tool the
|
|
28
|
+
project does not have is not a row — its absence belongs in `qualityCoverage`.
|
|
29
|
+
- `projectAnalysis.internalInterfaces` — the seams components call across, with the
|
|
30
|
+
signature a caller writes against. `kind` is what the seam is, not where its file
|
|
31
|
+
sits: `port` for an abstraction a domain declares, `adapter` for an implementation
|
|
32
|
+
that satisfies one. `ownerComponentId` and every `consumers` entry must name a
|
|
33
|
+
component this report declares.
|
|
34
|
+
- `projectAnalysis.workflows` — one run of real work through the system, start to
|
|
35
|
+
finish: what triggers it and which component does what, in order. This is the only
|
|
36
|
+
block that says how the parts are used rather than how they are arranged, so a
|
|
37
|
+
reader can follow a request without reconstructing it from the dependency graph.
|
|
38
|
+
Each step's `componentId` must name a declared component.
|
|
21
39
|
- Cross-verification mode:
|
|
22
40
|
- Phase 5.5 convergence runs in adversarial mode (`convergence.adversarial=true`).
|
|
23
41
|
- Non-goals:
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Release Handoff Profile
|
|
2
2
|
|
|
3
|
+
- Record the handoff shape in `releaseHandoff.handoffScope`: `mode` always, plus `stages`
|
|
4
|
+
and `collectorBranch` in stage-group mode. The report is the only place a reader learns
|
|
5
|
+
which stages shipped — the collector branch name does not say.
|
|
3
6
|
- Purpose: take an `accepted` final-verification verdict for an already-committed implementation branch and turn it into a delivered push and/or pull request, with explicit user selection at every mutating step. Two modes: **whole-task** (default — the verified task branch becomes one PR) and **stage-group** (a user-selected subset of verified stages is merged into a collector branch and becomes one PR).
|
|
4
7
|
- **Execution model: single-lead, no worker dispatch.** This phase is a thin orchestrator over `git` / `gh`; it does NOT dispatch teammates, does NOT dispatch analysis or drafter sub-agents, and does NOT run convergence. The host-native Okstra lead performs every step inline (drafting PR text, asking the user, running git / gh, writing the final report) — see "Lead-only contract" below.
|
|
5
8
|
- Worker roster: none — this profile intentionally has no `- Required workers:` block; the run is executed entirely by the Okstra lead.
|
|
@@ -25,6 +25,13 @@
|
|
|
25
25
|
- classify the work as bugfix, feature, improvement, refactor, or ops
|
|
26
26
|
- determine whether `error-analysis` or `implementation-planning` is the next safe step. Direct `implementation` handoff is never a valid routing target — implementation requires an approved `implementation-planning` report
|
|
27
27
|
- capture the reporter's **rejection criteria** — the delivered outcome that would make this work wrong or unacceptable — as a routing input. Consume it from the brief's `Desired Outcome` / `Out of Scope` / `Source Material` when present; when it is absent AND it would change the classification (e.g. bugfix vs feature) or the next-phase choice, raise it as one `decision` clarification row with `Evidence checked: none — reporter intent`. Never infer it — this is a reporter-intent signal, the mirror of improvement-discovery's `Anti-goals`
|
|
28
|
+
- record the rejection criteria in `requirementsDiscovery.rejectionCriteria` with the
|
|
29
|
+
`source` that produced it. When it was absent and would not have changed the
|
|
30
|
+
classification or the routing, say so with `source: absent-not-material` — an empty
|
|
31
|
+
field cannot be told apart from a phase that never looked
|
|
32
|
+
- record the terminology this phase settled in `requirementsDiscovery.domainAlignment`:
|
|
33
|
+
whether the glossary and decision records were read, and for every fuzzy or overloaded
|
|
34
|
+
term, the single canonical form and what decided it
|
|
28
35
|
- identify missing materials that block reliable routing
|
|
29
36
|
- define task continuity expectations for long-running work under the same task key
|
|
30
37
|
- capture approval or confirmation points before the next phase starts
|
|
@@ -1,64 +1,75 @@
|
|
|
1
1
|
"""Common data transformations that carry no task-specific section order."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Every block whose rows prose cites but no section of its own renders, with
|
|
8
|
+
# the keys each one names its content and its provenance by. A key hunt across
|
|
9
|
+
# all of them would misread `crossVerification.consensus`, whose `evidence` key
|
|
10
|
+
# holds provenance while `evidence.primary` uses the same word for content —
|
|
11
|
+
# so the block a row came from is named here rather than guessed.
|
|
12
|
+
_LEDGER_BLOCKS = (
|
|
13
|
+
(("evidence", "primary"), "evidence", "source", "unclassified"),
|
|
14
|
+
(("evidence", "secondary"), "hypothesis", "confidence", "hypothesis"),
|
|
15
|
+
(("analysisCommon", "confirmedFacts"), "statement", "", "confirmed fact"),
|
|
16
|
+
(("analysisCommon", "inferences"), "statement", "confidence", "inference"),
|
|
17
|
+
(("analysisCommon", "unknowns"), "question", "reason", "unknown"),
|
|
18
|
+
(("crossVerification", "consensus"), "statement", "evidence", "cross-check consensus"),
|
|
19
|
+
(("crossVerification", "differences"), "disagreement", "workersPosition", "cross-check dissent"),
|
|
20
|
+
(("missingInformation",), "item", "risk", "missing information"),
|
|
21
|
+
(("endStateCoverage",), "coveredBy", "evidence", "exit contract"),
|
|
22
|
+
(("followUpTasks",), "title", "reason", "follow-up"),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _dig(data: dict, path: tuple[str, ...]) -> list:
|
|
27
|
+
node: object = data
|
|
28
|
+
for key in path:
|
|
29
|
+
if not isinstance(node, dict):
|
|
30
|
+
return []
|
|
31
|
+
node = node.get(key)
|
|
32
|
+
return node if isinstance(node, list) else []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _ledger_row(row: dict, text_key: str, source_key: str, kind: str) -> dict[str, object]:
|
|
6
36
|
return {
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
37
|
+
"id": row.get("id", ""),
|
|
38
|
+
"kind": kind,
|
|
39
|
+
"text": str(row.get(text_key) or ""),
|
|
40
|
+
"codeEvidence": row.get("currentCodeEvidence") or [],
|
|
41
|
+
"source": str(row.get(source_key) or "") if source_key else "",
|
|
10
42
|
}
|
|
11
43
|
|
|
12
44
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _first_filled(row: dict, keys: tuple[str, ...]) -> str:
|
|
18
|
-
for key in keys:
|
|
19
|
-
value = row.get(key)
|
|
20
|
-
if value:
|
|
21
|
-
return str(value)
|
|
22
|
-
return ""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _ledger_row(row: dict) -> dict[str, object]:
|
|
26
|
-
"""Flatten one evidence row into the single shape the ledger renders.
|
|
45
|
+
def _own_section_ids(data: dict) -> set[str]:
|
|
46
|
+
"""Ids a section of the report already renders and anchors.
|
|
27
47
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
- ``evidence.primary`` — ``evidence`` / ``source``
|
|
33
|
-
- ``evidence.secondary`` — ``hypothesis`` / ``confidence``
|
|
34
|
-
- ``analysisCommon.confirmedFacts`` — ``statement`` / ``currentCodeEvidence``
|
|
35
|
-
- ``analysisCommon.inferences`` — ``statement`` / ``confidence``
|
|
36
|
-
- ``analysisCommon.unknowns`` — ``question`` / ``reason``
|
|
37
|
-
|
|
38
|
-
Only this function knows which block a row came from, so the key hunt
|
|
39
|
-
belongs here rather than in the template.
|
|
48
|
+
The ledger is the fallback home for a cited row, so it must not claim an id
|
|
49
|
+
that has one — two elements with the same anchor send half the links to the
|
|
50
|
+
wrong place. `crossVerification.consensus` numbers its rows `C-001` in some
|
|
51
|
+
runs, exactly where a clarification lives.
|
|
40
52
|
"""
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
from ..report_contract import TASK_TYPE_DATA_PROPERTY
|
|
54
|
+
|
|
55
|
+
found: set[str] = set()
|
|
56
|
+
_collect_ids(data.get("clarificationItems", []), found)
|
|
57
|
+
property_name = TASK_TYPE_DATA_PROPERTY.get(data.get("header", {}).get("taskType", ""))
|
|
58
|
+
if property_name:
|
|
59
|
+
_collect_ids(data.get(property_name, {}), found)
|
|
60
|
+
return found
|
|
47
61
|
|
|
48
62
|
|
|
49
63
|
def evidence_index(data: dict) -> dict[str, object]:
|
|
64
|
+
owned = _own_section_ids(data)
|
|
50
65
|
rows: dict[str, object] = {}
|
|
51
|
-
for
|
|
52
|
-
for row in data
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
rows[row_id] = _ledger_row(row)
|
|
56
|
-
analysis = data.get("analysisCommon", {})
|
|
57
|
-
for collection in ("confirmedFacts", "inferences", "unknowns"):
|
|
58
|
-
for row in analysis.get(collection, []):
|
|
66
|
+
for path, text_key, source_key, kind in _LEDGER_BLOCKS:
|
|
67
|
+
for row in _dig(data, path):
|
|
68
|
+
if not isinstance(row, dict):
|
|
69
|
+
continue
|
|
59
70
|
row_id = row.get("id")
|
|
60
|
-
if row_id:
|
|
61
|
-
rows[row_id] = _ledger_row(row)
|
|
71
|
+
if row_id and row_id not in owned and row_id not in rows:
|
|
72
|
+
rows[row_id] = _ledger_row(row, text_key, source_key, kind)
|
|
62
73
|
return rows
|
|
63
74
|
|
|
64
75
|
|
|
@@ -74,6 +85,25 @@ def _collect_ids(value: object, found: set[str]) -> None:
|
|
|
74
85
|
_collect_ids(nested, found)
|
|
75
86
|
|
|
76
87
|
|
|
88
|
+
_ROW_ID = re.compile(r"[A-Z]{1,3}-\d+")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def anchor_index(data: dict) -> dict[str, str]:
|
|
92
|
+
"""Map every row a reader can reach to the anchor name that lands on it.
|
|
93
|
+
|
|
94
|
+
Prose cites ids across section boundaries — a hotspot names a
|
|
95
|
+
cross-verification finding, a quality row names a difference — so the
|
|
96
|
+
target set spans the whole reader-facing report: the task's own sections,
|
|
97
|
+
the clarifications, and every block the ledger takes in.
|
|
98
|
+
|
|
99
|
+
It stops there. `summary` is the AI-facing digest and
|
|
100
|
+
`analysisCommon.scope` describes the analysis target rather than listing
|
|
101
|
+
rows; neither renders, so a link to one would land nowhere.
|
|
102
|
+
"""
|
|
103
|
+
found = _own_section_ids(data) | set(evidence_index(data))
|
|
104
|
+
return {row_id: f"id-{row_id}" for row_id in sorted(found) if _ROW_ID.fullmatch(row_id)}
|
|
105
|
+
|
|
106
|
+
|
|
77
107
|
def analysis_review_ids(data: dict) -> tuple[str, ...]:
|
|
78
108
|
found: set[str] = set()
|
|
79
109
|
_collect_ids(data.get("analysisCommon", {}), found)
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
"""Jinja filters for the task-specific HTML report."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
import html
|
|
5
|
-
import json
|
|
6
4
|
import re
|
|
7
5
|
|
|
8
6
|
import okstra_vendor # noqa: F401 # registers vendored dependency aliases
|
|
@@ -11,35 +9,43 @@ from markupsafe import Markup, escape
|
|
|
11
9
|
_INLINE_CODE = re.compile(r"`([^`]+)`")
|
|
12
10
|
_SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
|
|
13
11
|
_SENTENCES_PER_PARAGRAPH = 2
|
|
12
|
+
_ID_TOKEN = re.compile(r"\b[A-Z]{1,3}-\d+\b")
|
|
14
13
|
|
|
15
14
|
|
|
16
|
-
def
|
|
17
|
-
"""
|
|
15
|
+
def _link_ids(escaped: str, anchors: dict) -> str:
|
|
16
|
+
"""Anchor the row ids inside text that is already escaped.
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
Running after the escape keeps this anchor the only markup in the result.
|
|
19
|
+
Only ids the document defines become links — an id with no row would point
|
|
20
|
+
at a missing anchor, and a ticket number like ``DEV-10339`` has the same
|
|
21
|
+
shape as a row id without being one.
|
|
23
22
|
"""
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
if not anchors:
|
|
24
|
+
return escaped
|
|
26
25
|
|
|
26
|
+
def swap(match: re.Match) -> str:
|
|
27
|
+
name = anchors.get(match.group(0))
|
|
28
|
+
return f'<a href="#{name}">{match.group(0)}</a>' if name else match.group(0)
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
return _ID_TOKEN.sub(swap, escaped)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def inline_code(value: object, anchors: dict | None = None) -> Markup:
|
|
29
34
|
"""Convert paired backticks to ``<code>``, escaping everything else.
|
|
30
35
|
|
|
31
36
|
Workers author report prose with Markdown conventions, so an identifier
|
|
32
|
-
arrives wrapped in backticks. ``
|
|
33
|
-
|
|
37
|
+
arrives wrapped in backticks. ``anchors`` links the row ids cited in the
|
|
38
|
+
prose, inside backticks and out.
|
|
34
39
|
"""
|
|
40
|
+
index = anchors or {}
|
|
35
41
|
text = "" if value is None else str(value)
|
|
36
42
|
out: list[str] = []
|
|
37
43
|
cursor = 0
|
|
38
44
|
for match in _INLINE_CODE.finditer(text):
|
|
39
|
-
out.append(str(escape(text[cursor:match.start()])))
|
|
40
|
-
out.append(f"<code>{escape(match.group(1))}</code>")
|
|
45
|
+
out.append(_link_ids(str(escape(text[cursor:match.start()])), index))
|
|
46
|
+
out.append(f"<code>{_link_ids(str(escape(match.group(1))), index)}</code>")
|
|
41
47
|
cursor = match.end()
|
|
42
|
-
out.append(str(escape(text[cursor:])))
|
|
48
|
+
out.append(_link_ids(str(escape(text[cursor:])), index))
|
|
43
49
|
return Markup("".join(out))
|
|
44
50
|
|
|
45
51
|
|
|
@@ -56,7 +62,7 @@ def _grouped_paragraphs(text: str) -> list[str]:
|
|
|
56
62
|
]
|
|
57
63
|
|
|
58
64
|
|
|
59
|
-
def paragraphs(value: object) -> Markup:
|
|
65
|
+
def paragraphs(value: object, anchors: dict | None = None) -> Markup:
|
|
60
66
|
"""Split prose into ``<p>`` blocks, converting inline code inside each.
|
|
61
67
|
|
|
62
68
|
``userNarrative`` prose arrives as one unbroken run of sentences, so the
|
|
@@ -64,30 +70,119 @@ def paragraphs(value: object) -> Markup:
|
|
|
64
70
|
did supply blank lines keeps their own boundaries.
|
|
65
71
|
"""
|
|
66
72
|
text = "" if value is None else str(value)
|
|
67
|
-
return Markup(
|
|
73
|
+
return Markup(
|
|
74
|
+
"".join(f"<p>{inline_code(block, anchors)}</p>" for block in _grouped_paragraphs(text))
|
|
75
|
+
)
|
|
68
76
|
|
|
69
77
|
|
|
70
|
-
def evidence_refs(refs: object,
|
|
71
|
-
"""Render
|
|
78
|
+
def evidence_refs(refs: object, anchors: object) -> Markup:
|
|
79
|
+
"""Render a citation list, anchoring the entries the document defines.
|
|
72
80
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
no ``<code>`` wrapper.
|
|
81
|
+
The same index the prose links against decides here too — a citation and a
|
|
82
|
+
mid-sentence mention of the same id must land in the same place. No schema
|
|
83
|
+
behind this filter constrains an entry to an id: most of it is prose citing
|
|
84
|
+
a ``path:line``, which takes ``inline_code`` like every other prose cell.
|
|
78
85
|
"""
|
|
79
|
-
|
|
86
|
+
index = anchors if isinstance(anchors, dict) else {}
|
|
80
87
|
items = refs if isinstance(refs, (list, tuple)) else []
|
|
81
88
|
out: list[str] = []
|
|
82
89
|
for ref in items:
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
name = index.get(str(ref))
|
|
91
|
+
if name is None:
|
|
92
|
+
out.append(str(inline_code(ref, index)))
|
|
85
93
|
continue
|
|
86
|
-
|
|
87
|
-
out.append(f'<a href="#ev-{text}">{text}</a>')
|
|
94
|
+
out.append(f'<a href="#{name}">{escape(str(ref))}</a>')
|
|
88
95
|
return Markup(", ".join(out))
|
|
89
96
|
|
|
90
97
|
|
|
98
|
+
# Schema enums a reader has to decode. Each vocabulary is named so a value can
|
|
99
|
+
# mean different things in different fields without one table flattening them.
|
|
100
|
+
# A value with no entry renders as itself — a new enum member shows up raw
|
|
101
|
+
# rather than silently reading as something it is not.
|
|
102
|
+
ENUM_LABELS: dict[str, dict[str, str]] = {
|
|
103
|
+
"coverage": {
|
|
104
|
+
"covered": "A break fails a test",
|
|
105
|
+
"partial": "Partly covered",
|
|
106
|
+
"gap": "No test",
|
|
107
|
+
"risk": "Passes without proving",
|
|
108
|
+
},
|
|
109
|
+
"effort": {"S": "Small", "M": "Medium", "L": "Large", "XL": "Very large"},
|
|
110
|
+
"versionSource": {
|
|
111
|
+
"manifest-range": "Manifest range",
|
|
112
|
+
"lockfile-pinned": "Lockfile pin",
|
|
113
|
+
"container-image": "Container image",
|
|
114
|
+
"runtime-config": "Runtime config",
|
|
115
|
+
"declared-doc": "Declared in docs",
|
|
116
|
+
},
|
|
117
|
+
"interfaceKind": {
|
|
118
|
+
"port": "Port",
|
|
119
|
+
"adapter": "Adapter",
|
|
120
|
+
"service-api": "Service API",
|
|
121
|
+
"module-export": "Module export",
|
|
122
|
+
"event": "Event",
|
|
123
|
+
},
|
|
124
|
+
"toolKind": {
|
|
125
|
+
"packageManager": "Package manager",
|
|
126
|
+
"build": "Build",
|
|
127
|
+
"test": "Test",
|
|
128
|
+
"lint": "Lint",
|
|
129
|
+
"typecheck": "Type check",
|
|
130
|
+
"format": "Format",
|
|
131
|
+
"ci": "CI",
|
|
132
|
+
"container": "Container",
|
|
133
|
+
},
|
|
134
|
+
"lens": {"candidate-found": "Candidate found", "no-candidate": "Nothing found"},
|
|
135
|
+
"rejectionSource": {
|
|
136
|
+
"brief-desired-outcome": "Brief · desired outcome",
|
|
137
|
+
"brief-out-of-scope": "Brief · out of scope",
|
|
138
|
+
"brief-source-material": "Brief · source material",
|
|
139
|
+
"clarification-raised": "Raised as a question",
|
|
140
|
+
"absent-not-material": "Absent · does not change the call",
|
|
141
|
+
},
|
|
142
|
+
"termSource": {
|
|
143
|
+
"glossary": "Glossary",
|
|
144
|
+
"decision-record": "Decision record",
|
|
145
|
+
"brief": "Brief",
|
|
146
|
+
"clarification": "Your answer",
|
|
147
|
+
},
|
|
148
|
+
"handoffMode": {"whole-task": "Whole task", "stage-group": "Selected stages"},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# What each member of a vocabulary actually claims. A two-word label fits a
|
|
153
|
+
# table cell but cannot carry a definition, and a reader meeting "Passes
|
|
154
|
+
# without proving" for the first time has nowhere to look it up. Only
|
|
155
|
+
# vocabularies whose members are judgements need one; a kind names itself.
|
|
156
|
+
ENUM_HINTS: dict[str, dict[str, str]] = {
|
|
157
|
+
"coverage": {
|
|
158
|
+
"covered": "Break this area and a test fails.",
|
|
159
|
+
"partial": "Tests exist, but part of the area runs through none of them.",
|
|
160
|
+
"gap": "No test runs through this area at all.",
|
|
161
|
+
"risk": (
|
|
162
|
+
"Tests run and pass, but they skip or assert nothing, so passing "
|
|
163
|
+
"is not evidence that the area works. More dangerous than "
|
|
164
|
+
"'No test', because the suite reports success."
|
|
165
|
+
),
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def enum_label(value: object, vocabulary: str) -> str:
|
|
171
|
+
"""Render a schema enum as the words it stands for."""
|
|
172
|
+
raw = "" if value is None else str(value)
|
|
173
|
+
return ENUM_LABELS.get(vocabulary, {}).get(raw, raw)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def enum_legend(vocabulary: str) -> list[tuple[str, str]]:
|
|
177
|
+
"""Pair each label with what it claims, for a legend beside the table.
|
|
178
|
+
|
|
179
|
+
Built from the same two tables the cells render from, so a legend cannot
|
|
180
|
+
drift from the labels it explains.
|
|
181
|
+
"""
|
|
182
|
+
labels = ENUM_LABELS.get(vocabulary, {})
|
|
183
|
+
return [(labels.get(value, value), hint) for value, hint in ENUM_HINTS.get(vocabulary, {}).items()]
|
|
184
|
+
|
|
185
|
+
|
|
91
186
|
def code_evidence(rows: object) -> Markup:
|
|
92
187
|
"""Render ``currentCodeEvidence`` entries as ``path:line`` code spans."""
|
|
93
188
|
items = rows if isinstance(rows, (list, tuple)) else []
|
|
@@ -11,15 +11,30 @@ class HtmlRunMeta:
|
|
|
11
11
|
task_type: str
|
|
12
12
|
seq: str
|
|
13
13
|
source_report: str
|
|
14
|
+
# Wall-clock milliseconds for the run, or None when the team-state
|
|
15
|
+
# carried no timestamps to measure between.
|
|
16
|
+
elapsed_ms: int | None = None
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
@dataclass(frozen=True)
|
|
17
20
|
class VisualNode:
|
|
18
21
|
id: str
|
|
19
22
|
label: str
|
|
23
|
+
# Which column the figure puts this node in, and which fill it draws it
|
|
24
|
+
# with. Both are drawing instructions: `group` is often a path prefix and
|
|
25
|
+
# `status` is often a constant. The text table printed them raw under the
|
|
26
|
+
# headings "group" and "status", so in a single-root project every
|
|
27
|
+
# component read "src · stable" — a layout key and a literal, neither a
|
|
28
|
+
# fact about the project. Anything the reader should see goes in `note`.
|
|
20
29
|
group: str
|
|
21
30
|
status: str
|
|
22
31
|
detail: str
|
|
32
|
+
# Files the node stands for. The figure's text alternative is the only
|
|
33
|
+
# place a reader can look them up, so they travel with the node.
|
|
34
|
+
paths: tuple[str, ...] = ()
|
|
35
|
+
# What this node is, in the reader's words. Empty when the figure has
|
|
36
|
+
# nothing to say beyond the label.
|
|
37
|
+
note: str = ""
|
|
23
38
|
|
|
24
39
|
|
|
25
40
|
@dataclass(frozen=True)
|
|
@@ -10,7 +10,15 @@ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoes
|
|
|
10
10
|
|
|
11
11
|
from ..final_report_schema import load_schema_for_data, validate
|
|
12
12
|
from ..report_view_artifacts import user_responses_dir_for_report
|
|
13
|
-
from .
|
|
13
|
+
from .common import anchor_index
|
|
14
|
+
from .filters import (
|
|
15
|
+
code_evidence,
|
|
16
|
+
enum_label,
|
|
17
|
+
enum_legend,
|
|
18
|
+
evidence_refs,
|
|
19
|
+
inline_code,
|
|
20
|
+
paragraphs,
|
|
21
|
+
)
|
|
14
22
|
from .models import HtmlRunMeta
|
|
15
23
|
from .router import HtmlRenderError, resolve_html_route
|
|
16
24
|
|
|
@@ -30,6 +38,32 @@ def _templates_root(start: Path | None = None) -> Path:
|
|
|
30
38
|
raise HtmlRenderError("could not locate templates/reports")
|
|
31
39
|
|
|
32
40
|
|
|
41
|
+
def _elapsed_text(elapsed_ms: int | None) -> str | None:
|
|
42
|
+
"""Render a run duration, or nothing when there is none to render.
|
|
43
|
+
|
|
44
|
+
A run whose team-state never recorded timestamps has no measured duration;
|
|
45
|
+
printing "0m" would claim it finished instantly.
|
|
46
|
+
"""
|
|
47
|
+
if not elapsed_ms or elapsed_ms < 0:
|
|
48
|
+
return None
|
|
49
|
+
minutes, seconds = divmod(round(elapsed_ms / 1000), 60)
|
|
50
|
+
hours, minutes = divmod(minutes, 60)
|
|
51
|
+
if hours:
|
|
52
|
+
return f"{hours}h {minutes}m"
|
|
53
|
+
if minutes:
|
|
54
|
+
return f"{minutes}m {seconds}s"
|
|
55
|
+
return f"{seconds}s"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _report_meta(data: dict, run_meta: HtmlRunMeta) -> dict[str, object]:
|
|
59
|
+
return {
|
|
60
|
+
"createdAt": data.get("header", {}).get("createdAt", ""),
|
|
61
|
+
"taskTitle": data.get("frontmatter", {}).get("title", ""),
|
|
62
|
+
"taskKey": run_meta.task_key,
|
|
63
|
+
"elapsed": _elapsed_text(run_meta.elapsed_ms),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
33
67
|
def _html_path(data_path: Path) -> Path:
|
|
34
68
|
suffix = ".data.json"
|
|
35
69
|
if not data_path.name.endswith(suffix):
|
|
@@ -55,16 +89,21 @@ def render_v2_html_view(
|
|
|
55
89
|
root = _templates_root(templates_root)
|
|
56
90
|
env = Environment(loader=FileSystemLoader(str(root)), autoescape=select_autoescape(("html",)), undefined=StrictUndefined)
|
|
57
91
|
env.policies["json.dumps_kwargs"] = {"sort_keys": True, "ensure_ascii": False}
|
|
58
|
-
|
|
92
|
+
# Binding the index here is what lets a template cite an id without
|
|
93
|
+
# threading the index through every macro and call site.
|
|
94
|
+
anchors = anchor_index(data)
|
|
59
95
|
env.filters["code_evidence"] = code_evidence
|
|
60
|
-
env.filters["
|
|
61
|
-
env.filters["
|
|
62
|
-
env.filters["
|
|
96
|
+
env.filters["enum_label"] = enum_label
|
|
97
|
+
env.filters["enum_legend"] = enum_legend
|
|
98
|
+
env.filters["evidence_refs"] = lambda refs: evidence_refs(refs, anchors)
|
|
99
|
+
env.filters["inline_code"] = lambda value: inline_code(value, anchors)
|
|
100
|
+
env.filters["paragraphs"] = lambda value: paragraphs(value, anchors)
|
|
63
101
|
response_js = (root / "report.js").read_text(encoding="utf-8")
|
|
64
102
|
base_js = (root / "html/assets/base.js").read_text(encoding="utf-8")
|
|
65
103
|
context = {
|
|
66
104
|
**view.context,
|
|
67
105
|
"runMeta": run_meta,
|
|
106
|
+
"reportMeta": _report_meta(data, run_meta),
|
|
68
107
|
"taskType": view.task_type,
|
|
69
108
|
"dataSha256": _sha256(data_path),
|
|
70
109
|
"markdownSha256": _sha256(markdown_path),
|
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
"""Human-first change-impact-analysis view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import analysis_review_ids,
|
|
4
|
+
from ..common import analysis_review_ids, evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
6
|
from ..visualizations import flow_figure
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def _impact_figure(change: dict):
|
|
10
10
|
nodes = [
|
|
11
|
-
VisualNode(
|
|
11
|
+
VisualNode(
|
|
12
|
+
row["id"],
|
|
13
|
+
row["target"],
|
|
14
|
+
"affected",
|
|
15
|
+
row["level"],
|
|
16
|
+
row["impactKind"],
|
|
17
|
+
note=f'Impact {row["level"]}',
|
|
18
|
+
)
|
|
12
19
|
for row in change.get("impactItems", [])
|
|
13
20
|
]
|
|
14
21
|
edges: list[VisualEdge] = []
|
|
15
22
|
for index, row in enumerate(change.get("dependencyBlastRadius", []), start=1):
|
|
16
23
|
target_id = f"BR-{index:03d}"
|
|
17
|
-
nodes.append(
|
|
24
|
+
nodes.append(
|
|
25
|
+
VisualNode(
|
|
26
|
+
target_id, row["affectedTarget"], "downstream", "risk", row["direction"], note="Downstream"
|
|
27
|
+
)
|
|
28
|
+
)
|
|
18
29
|
edges.append(VisualEdge(row["sourceImpactId"], target_id, row["direction"], "impact"))
|
|
19
30
|
return flow_figure(nodes=tuple(nodes), edges=tuple(edges), title="Change blast radius")
|
|
20
31
|
|
|
@@ -29,7 +40,6 @@ def build_change_impact_analysis_view(data: dict) -> HumanReportView:
|
|
|
29
40
|
"blastRadiusFigure": figure,
|
|
30
41
|
"analysisReviewIds": analysis_review_ids(data),
|
|
31
42
|
"evidenceIndex": evidence_index(data),
|
|
32
|
-
"audit": audit_context(data),
|
|
33
43
|
}
|
|
34
44
|
return HumanReportView(
|
|
35
45
|
task_type="change-impact-analysis",
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"""Human-first error-analysis view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import
|
|
4
|
+
from ..common import evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
6
|
from ..visualizations import cause_graph_figure
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def _cause_figure(error: dict):
|
|
10
10
|
symptom = VisualNode(
|
|
11
|
-
"symptom", "Observed failure", "effect", "risk", error["observableFailure"]
|
|
11
|
+
"symptom", "Observed failure", "effect", "risk", error["observableFailure"], note="Observed failure"
|
|
12
12
|
)
|
|
13
13
|
causes = tuple(
|
|
14
14
|
VisualNode(
|
|
@@ -17,6 +17,7 @@ def _cause_figure(error: dict):
|
|
|
17
17
|
"candidate",
|
|
18
18
|
row["confidence"],
|
|
19
19
|
row["disproveWith"],
|
|
20
|
+
note=f'Confidence {row["confidence"]}',
|
|
20
21
|
)
|
|
21
22
|
for row in error.get("causeCandidates", [])
|
|
22
23
|
)
|
|
@@ -39,7 +40,6 @@ def build_error_analysis_view(data: dict) -> HumanReportView:
|
|
|
39
40
|
"narrative": error["userNarrative"],
|
|
40
41
|
"causeFigure": figure,
|
|
41
42
|
"evidenceIndex": evidence_index(data),
|
|
42
|
-
"audit": audit_context(data),
|
|
43
43
|
}
|
|
44
44
|
return HumanReportView(
|
|
45
45
|
"error-analysis",
|