okstra 0.149.0 → 0.150.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/docs/architecture.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/project-structure-overview.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/translator-worker.md +67 -0
- package/runtime/bin/okstra-render-final-report.py +0 -11
- package/runtime/bin/okstra-report-translate.py +158 -0
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
- package/runtime/prompts/lead/report-writer.md +14 -13
- package/runtime/prompts/lead/team-contract.md +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +17 -1
- package/runtime/python/okstra_ctl/analysis_inputs.py +24 -9
- package/runtime/python/okstra_ctl/analysis_packet.py +23 -1
- package/runtime/python/okstra_ctl/clarification_items.py +241 -44
- package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +2 -2
- package/runtime/python/okstra_ctl/dispatch_state.py +12 -1
- package/runtime/python/okstra_ctl/final_report_paths.py +22 -1
- package/runtime/python/okstra_ctl/i18n.py +12 -7
- package/runtime/python/okstra_ctl/render_final_report.py +18 -17
- package/runtime/python/okstra_ctl/report_html/filters.py +15 -77
- package/runtime/python/okstra_ctl/report_html/render.py +44 -2
- package/runtime/python/okstra_ctl/report_translation.py +440 -0
- package/runtime/python/okstra_ctl/report_views.py +23 -9
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/user_response.py +11 -6
- package/runtime/python/okstra_ctl/wizard.py +100 -25
- package/runtime/python/okstra_ctl/worker_liveness.py +130 -36
- package/runtime/templates/reports/html/base.template.html +12 -12
- package/runtime/templates/reports/html/i18n/en.json +395 -0
- package/runtime/templates/reports/html/i18n/ko.json +395 -0
- package/runtime/templates/reports/html/macros/forms.html +16 -16
- package/runtime/templates/reports/html/macros/visualizations.html +2 -2
- package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +17 -17
- package/runtime/templates/reports/html/tasks/error-analysis.template.html +12 -12
- package/runtime/templates/reports/html/tasks/feature-analysis.template.html +16 -16
- package/runtime/templates/reports/html/tasks/final-verification.template.html +12 -12
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +37 -37
- package/runtime/templates/reports/html/tasks/implementation.template.html +18 -18
- package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +7 -7
- package/runtime/templates/reports/html/tasks/project-analysis.template.html +29 -29
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +13 -13
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +14 -14
- package/runtime/templates/reports/report.js +8 -5
- package/runtime/validators/validate-report-views.py +1 -1
- package/runtime/validators/validate-run.py +28 -31
- package/src/cli-registry.mjs +11 -0
- package/src/commands/inspect/worker-liveness.mjs +9 -7
- package/src/commands/report/translate.mjs +31 -0
- package/src/lib/helper-scripts.mjs +1 -0
- package/runtime/templates/reports/i18n/ko.json +0 -273
|
@@ -97,90 +97,28 @@ def evidence_refs(refs: object, anchors: object) -> Markup:
|
|
|
97
97
|
|
|
98
98
|
# Schema enums a reader has to decode. Each vocabulary is named so a value can
|
|
99
99
|
# mean different things in different fields without one table flattening them.
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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:
|
|
100
|
+
# The tables live in `templates/reports/html/i18n/<lang>.json` beside the rest
|
|
101
|
+
# of the page's fixed strings — a label is chrome, not data, so it follows the
|
|
102
|
+
# reader's language rather than the SSOT's. A value with no entry renders as
|
|
103
|
+
# itself: a new enum member shows up raw rather than silently reading as
|
|
104
|
+
# something it is not.
|
|
105
|
+
def enum_label(value: object, vocabulary: str, chrome: dict) -> str:
|
|
171
106
|
"""Render a schema enum as the words it stands for."""
|
|
172
107
|
raw = "" if value is None else str(value)
|
|
173
|
-
return
|
|
108
|
+
return (chrome.get("enum") or {}).get(vocabulary, {}).get(raw, raw)
|
|
174
109
|
|
|
175
110
|
|
|
176
|
-
def enum_legend(vocabulary: str) -> list[tuple[str, str]]:
|
|
111
|
+
def enum_legend(vocabulary: str, chrome: dict) -> list[tuple[str, str]]:
|
|
177
112
|
"""Pair each label with what it claims, for a legend beside the table.
|
|
178
113
|
|
|
179
|
-
|
|
180
|
-
|
|
114
|
+
A two-word label fits a table cell but cannot carry a definition, and a
|
|
115
|
+
reader meeting "Passes without proving" for the first time has nowhere to
|
|
116
|
+
look it up. Built from the same dictionary the cells render from, so a
|
|
117
|
+
legend cannot drift from the labels it explains.
|
|
181
118
|
"""
|
|
182
|
-
labels =
|
|
183
|
-
|
|
119
|
+
labels = (chrome.get("enum") or {}).get(vocabulary, {})
|
|
120
|
+
hints = (chrome.get("enumHint") or {}).get(vocabulary, {})
|
|
121
|
+
return [(labels.get(value, value), hint) for value, hint in hints.items()]
|
|
184
122
|
|
|
185
123
|
|
|
186
124
|
def code_evidence(rows: object) -> Markup:
|
|
@@ -3,12 +3,16 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import hashlib
|
|
5
5
|
import json
|
|
6
|
+
import sys
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
import okstra_vendor # noqa: F401 # registers vendored dependency aliases
|
|
9
10
|
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
|
|
10
11
|
|
|
12
|
+
from ..final_report_paths import translation_sidecar_path
|
|
11
13
|
from ..final_report_schema import load_schema_for_data, validate
|
|
14
|
+
from ..i18n import HTML_DICTIONARY_REL, load_dictionary, make_jinja_global
|
|
15
|
+
from ..report_translation import overlay
|
|
12
16
|
from ..report_view_artifacts import user_responses_dir_for_report
|
|
13
17
|
from .common import anchor_index
|
|
14
18
|
from .filters import (
|
|
@@ -64,6 +68,39 @@ def _report_meta(data: dict, run_meta: HtmlRunMeta) -> dict[str, object]:
|
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
|
|
71
|
+
def _localize(data: dict, data_path: Path) -> tuple[dict, str]:
|
|
72
|
+
"""Apply the translation sidecar for this report's language, if there is one.
|
|
73
|
+
|
|
74
|
+
The data.json is the English SSOT and is never rewritten — the sidecar is
|
|
75
|
+
read here and overlaid onto an in-memory copy, so only this HTML document
|
|
76
|
+
speaks the reader's language. A pointer the sidecar left untranslated
|
|
77
|
+
renders in English; that keeps a half-finished translation readable, and
|
|
78
|
+
the counts are printed because a silently half-empty sidecar otherwise
|
|
79
|
+
ships looking finished.
|
|
80
|
+
"""
|
|
81
|
+
lang = str((data.get("meta") or {}).get("reportLanguage") or "en")
|
|
82
|
+
if lang == "en":
|
|
83
|
+
return data, lang
|
|
84
|
+
sidecar_file = translation_sidecar_path(data_path, lang)
|
|
85
|
+
if not sidecar_file.is_file():
|
|
86
|
+
sys.stdout.write(
|
|
87
|
+
f"note: no {lang} translation sidecar at {sidecar_file.name}; "
|
|
88
|
+
"rendering the English source\n"
|
|
89
|
+
)
|
|
90
|
+
return data, lang
|
|
91
|
+
payload = json.loads(sidecar_file.read_text(encoding="utf-8"))
|
|
92
|
+
strings = payload.get("strings")
|
|
93
|
+
if not isinstance(strings, dict):
|
|
94
|
+
raise HtmlRenderError(f"translation sidecar has no 'strings' object: {sidecar_file}")
|
|
95
|
+
localized, report = overlay(data, strings)
|
|
96
|
+
sys.stdout.write(
|
|
97
|
+
f"translated {report.applied} string(s) into {lang}"
|
|
98
|
+
f" ({len(report.untranslated)} left in English"
|
|
99
|
+
f", {len(report.unresolved)} unresolved)\n"
|
|
100
|
+
)
|
|
101
|
+
return localized, lang
|
|
102
|
+
|
|
103
|
+
|
|
67
104
|
def _html_path(data_path: Path) -> Path:
|
|
68
105
|
suffix = ".data.json"
|
|
69
106
|
if not data_path.name.endswith(suffix):
|
|
@@ -84,6 +121,9 @@ def render_v2_html_view(
|
|
|
84
121
|
raise HtmlRenderError("invalid v2 final-report data: " + "; ".join(errors[:5]))
|
|
85
122
|
if not markdown_path.is_file():
|
|
86
123
|
raise HtmlRenderError(f"v2 markdown sibling not found: {markdown_path}")
|
|
124
|
+
# Validate the SSOT, then localize — the sidecar carries presentation and
|
|
125
|
+
# has no say in whether the report is well-formed.
|
|
126
|
+
data, lang = _localize(data, data_path)
|
|
87
127
|
route = resolve_html_route(run_meta.task_type)
|
|
88
128
|
view = route.view_builder(data)
|
|
89
129
|
root = _templates_root(templates_root)
|
|
@@ -92,9 +132,11 @@ def render_v2_html_view(
|
|
|
92
132
|
# Binding the index here is what lets a template cite an id without
|
|
93
133
|
# threading the index through every macro and call site.
|
|
94
134
|
anchors = anchor_index(data)
|
|
135
|
+
chrome = load_dictionary(lang, HTML_DICTIONARY_REL)
|
|
136
|
+
env.globals["t"] = make_jinja_global(chrome)
|
|
95
137
|
env.filters["code_evidence"] = code_evidence
|
|
96
|
-
env.filters["enum_label"] = enum_label
|
|
97
|
-
env.filters["enum_legend"] = enum_legend
|
|
138
|
+
env.filters["enum_label"] = lambda value, vocabulary: enum_label(value, vocabulary, chrome)
|
|
139
|
+
env.filters["enum_legend"] = lambda vocabulary: enum_legend(vocabulary, chrome)
|
|
98
140
|
env.filters["evidence_refs"] = lambda refs: evidence_refs(refs, anchors)
|
|
99
141
|
env.filters["inline_code"] = lambda value: inline_code(value, anchors)
|
|
100
142
|
env.filters["paragraphs"] = lambda value: paragraphs(value, anchors)
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Which final-report strings a translator may rewrite, and how to apply them.
|
|
2
|
+
|
|
3
|
+
The data.json is authored in English and is the SSOT for every downstream
|
|
4
|
+
consumer. The human HTML is the one artifact that renders in the reader's
|
|
5
|
+
language, so a translation arrives as a sidecar keyed by JSON Pointer and is
|
|
6
|
+
overlaid at render time. Nothing here mutates the SSOT.
|
|
7
|
+
|
|
8
|
+
Classification is by leaf key name, and the split is not "prose vs. term" —
|
|
9
|
+
the translator is told to leave a term in English when English reads better.
|
|
10
|
+
The line drawn here is **load-bearing vs. displayed**: a value the renderer
|
|
11
|
+
feeds into a CSS class, a label-table lookup, or an anchor id cannot change
|
|
12
|
+
without breaking the page, so it never reaches the translator. Everything a
|
|
13
|
+
reader merely reads is offered, and the translator decides.
|
|
14
|
+
|
|
15
|
+
When a key name carries load-bearing values in even one place, it counts as
|
|
16
|
+
structural everywhere. Missing a translation leaves English on the page;
|
|
17
|
+
translating an anchor id breaks the link.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from typing import Any, Iterator, Mapping, NamedTuple
|
|
23
|
+
|
|
24
|
+
# Values the renderer reads as text and nothing else.
|
|
25
|
+
PROSE_KEYS = frozenset({
|
|
26
|
+
"acceptance",
|
|
27
|
+
"alternativesConsidered",
|
|
28
|
+
"approach",
|
|
29
|
+
"approvalDisposition",
|
|
30
|
+
"approvalEvidence",
|
|
31
|
+
"behavior",
|
|
32
|
+
"blastRadius",
|
|
33
|
+
"blockReason",
|
|
34
|
+
"blockedReason",
|
|
35
|
+
"body",
|
|
36
|
+
"briefEvidence",
|
|
37
|
+
"carryIn",
|
|
38
|
+
"causeSummary",
|
|
39
|
+
"change",
|
|
40
|
+
"check",
|
|
41
|
+
"claim",
|
|
42
|
+
"condition",
|
|
43
|
+
"confirmingSignal",
|
|
44
|
+
"conformanceExemption",
|
|
45
|
+
"consequences",
|
|
46
|
+
"constraint",
|
|
47
|
+
"context",
|
|
48
|
+
"coreReason",
|
|
49
|
+
"decision",
|
|
50
|
+
"declinedFixRecommendations",
|
|
51
|
+
"description",
|
|
52
|
+
"details",
|
|
53
|
+
"disagreement",
|
|
54
|
+
"discrepancy",
|
|
55
|
+
"disproveWith",
|
|
56
|
+
"environmentSetup",
|
|
57
|
+
"escalationTrigger",
|
|
58
|
+
"evidence",
|
|
59
|
+
"evidenceRequired",
|
|
60
|
+
"exemptionReaffirm",
|
|
61
|
+
"exemptionReason",
|
|
62
|
+
"exitContract",
|
|
63
|
+
"exitContractSummary",
|
|
64
|
+
"expected",
|
|
65
|
+
"expectedBehaviorAfter",
|
|
66
|
+
"expectedForm",
|
|
67
|
+
"expectedOutcome",
|
|
68
|
+
"expectedResult",
|
|
69
|
+
"failureHandling",
|
|
70
|
+
"finalConclusion",
|
|
71
|
+
"headline",
|
|
72
|
+
"howToStart",
|
|
73
|
+
"hypothesis",
|
|
74
|
+
"item",
|
|
75
|
+
"justification",
|
|
76
|
+
"label",
|
|
77
|
+
"mitigation",
|
|
78
|
+
"motivation",
|
|
79
|
+
"name",
|
|
80
|
+
"need",
|
|
81
|
+
"nextStep",
|
|
82
|
+
"noVariationRationale",
|
|
83
|
+
"notApplicableReason",
|
|
84
|
+
"note",
|
|
85
|
+
"observableFailure",
|
|
86
|
+
"observed",
|
|
87
|
+
"option",
|
|
88
|
+
"outcome",
|
|
89
|
+
"outputSummary",
|
|
90
|
+
"performed",
|
|
91
|
+
"planItem",
|
|
92
|
+
"position",
|
|
93
|
+
"problem",
|
|
94
|
+
"question",
|
|
95
|
+
"rationale",
|
|
96
|
+
"readyWhen",
|
|
97
|
+
"reason",
|
|
98
|
+
"rejectedSummary",
|
|
99
|
+
"rejectingSignal",
|
|
100
|
+
"requiredDecision",
|
|
101
|
+
"requiredWork",
|
|
102
|
+
"requirement",
|
|
103
|
+
"resolution",
|
|
104
|
+
"responsibility",
|
|
105
|
+
"rolloutCost",
|
|
106
|
+
"routingRecommendation",
|
|
107
|
+
"selfFixNote",
|
|
108
|
+
"stageTitle",
|
|
109
|
+
"stageValidation",
|
|
110
|
+
"statement",
|
|
111
|
+
"suggestedAction",
|
|
112
|
+
"summary",
|
|
113
|
+
"supersededStatement",
|
|
114
|
+
"symptom",
|
|
115
|
+
"systemInterpretation",
|
|
116
|
+
"testCaseBoundary",
|
|
117
|
+
"testCaseFailure",
|
|
118
|
+
"testCaseSuccess",
|
|
119
|
+
"testCoverageCost",
|
|
120
|
+
"text",
|
|
121
|
+
"title",
|
|
122
|
+
"topic",
|
|
123
|
+
"trigger",
|
|
124
|
+
"triggerSignal",
|
|
125
|
+
"userChoice",
|
|
126
|
+
"userInput",
|
|
127
|
+
"verification",
|
|
128
|
+
"verificationMethod",
|
|
129
|
+
"verificationSignal",
|
|
130
|
+
"workingAssumption",
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
# Values something other than the reader depends on: a CSS class, a label-table
|
|
134
|
+
# key, an anchor id, a path the reader clicks, a command they paste, or a quote
|
|
135
|
+
# that must stay verbatim to be evidence at all.
|
|
136
|
+
STRUCTURAL_KEYS = frozenset({
|
|
137
|
+
"action", # implementation.py: _FILE_ACTIONS lookup key
|
|
138
|
+
"adapter",
|
|
139
|
+
"affectedId",
|
|
140
|
+
"affectedTarget",
|
|
141
|
+
"area", # slug-shaped ('scan-coverage')
|
|
142
|
+
"artifact",
|
|
143
|
+
"baseBranch",
|
|
144
|
+
"baseRefSha",
|
|
145
|
+
"baseSha",
|
|
146
|
+
"boundary",
|
|
147
|
+
"branchName",
|
|
148
|
+
"canonical",
|
|
149
|
+
"capturedHeadSha",
|
|
150
|
+
"carriedForwardFromSeq",
|
|
151
|
+
"carryJson",
|
|
152
|
+
"clarificationId",
|
|
153
|
+
"claudeCode",
|
|
154
|
+
"collectorBranch",
|
|
155
|
+
"command",
|
|
156
|
+
"commandOrObservation",
|
|
157
|
+
"commandOrTest",
|
|
158
|
+
"commitListQuote",
|
|
159
|
+
"complexity",
|
|
160
|
+
"componentId",
|
|
161
|
+
"confidence",
|
|
162
|
+
"conformanceTests",
|
|
163
|
+
"coveredBy",
|
|
164
|
+
"createdAt",
|
|
165
|
+
"cycle",
|
|
166
|
+
"dataStore",
|
|
167
|
+
"date",
|
|
168
|
+
"dependsOn",
|
|
169
|
+
"diffSummaryQuote",
|
|
170
|
+
"direction",
|
|
171
|
+
"dispatches",
|
|
172
|
+
"executorWorktreePath",
|
|
173
|
+
"existingPrUrl",
|
|
174
|
+
"externalSystem",
|
|
175
|
+
"featureId",
|
|
176
|
+
"field",
|
|
177
|
+
"file",
|
|
178
|
+
"files",
|
|
179
|
+
"followUpPhase",
|
|
180
|
+
"fromComponentId",
|
|
181
|
+
"fromTaskKey",
|
|
182
|
+
"fullSha",
|
|
183
|
+
"gitDiffStat",
|
|
184
|
+
"gitStatusShort",
|
|
185
|
+
"h1c",
|
|
186
|
+
"h2",
|
|
187
|
+
"id",
|
|
188
|
+
"impact", # 'high' / 'medium' alongside prose
|
|
189
|
+
"impactKind",
|
|
190
|
+
"implementationBaseRef",
|
|
191
|
+
"implementationOption",
|
|
192
|
+
"independentValidationRerun",
|
|
193
|
+
"injectedAs",
|
|
194
|
+
"input",
|
|
195
|
+
"interfaceKind",
|
|
196
|
+
"interfaces",
|
|
197
|
+
"itemId",
|
|
198
|
+
"kind", # feature_analysis.py: _FLOW_KINDS lookup key
|
|
199
|
+
"leadModel",
|
|
200
|
+
"leadingCauseId",
|
|
201
|
+
"lens", # enum_label("lens")
|
|
202
|
+
"lines",
|
|
203
|
+
"linkedWork",
|
|
204
|
+
"match",
|
|
205
|
+
"model",
|
|
206
|
+
"newTaskId",
|
|
207
|
+
"number",
|
|
208
|
+
"okstraVersion",
|
|
209
|
+
"output",
|
|
210
|
+
"outputTail",
|
|
211
|
+
"owner",
|
|
212
|
+
"ownerComponentId",
|
|
213
|
+
"path",
|
|
214
|
+
"planFile",
|
|
215
|
+
"planStep",
|
|
216
|
+
"prepItemId",
|
|
217
|
+
"project",
|
|
218
|
+
"projectId",
|
|
219
|
+
"raw",
|
|
220
|
+
"rawStat",
|
|
221
|
+
"readBoundary",
|
|
222
|
+
"readOnlyCommandLog",
|
|
223
|
+
"relatedIds",
|
|
224
|
+
"relation",
|
|
225
|
+
"replacedInTest",
|
|
226
|
+
"replacedWith",
|
|
227
|
+
"reportPath",
|
|
228
|
+
"requestKind",
|
|
229
|
+
"requestPath",
|
|
230
|
+
"requestVerbatim", # the user's own words, quoted as evidence
|
|
231
|
+
"requestedValue",
|
|
232
|
+
"reversibility",
|
|
233
|
+
"risk", # tone-{{ row.risk }} class; an enum on ProjectHotspot
|
|
234
|
+
"role",
|
|
235
|
+
"rollbackCommand",
|
|
236
|
+
"runManifest",
|
|
237
|
+
"runSeq",
|
|
238
|
+
"scope", # 'PF-001' alongside prose
|
|
239
|
+
"sections",
|
|
240
|
+
"shortSha",
|
|
241
|
+
"signature",
|
|
242
|
+
"singleTicket",
|
|
243
|
+
"skippedWorkers",
|
|
244
|
+
"sliceValue",
|
|
245
|
+
"slug",
|
|
246
|
+
"source", # enum_label("rejectionSource") and evidence ids
|
|
247
|
+
"sourceCommit",
|
|
248
|
+
"sourceFile",
|
|
249
|
+
"sourceImpactId",
|
|
250
|
+
"sourceSection",
|
|
251
|
+
"status", # CSS class + enum_label("coverage")
|
|
252
|
+
"styleLintTypecheck",
|
|
253
|
+
"subject", # commit subject, must stay verbatim
|
|
254
|
+
"symbol",
|
|
255
|
+
"symptomVerbatim",
|
|
256
|
+
"target",
|
|
257
|
+
"targetReport",
|
|
258
|
+
"taskGroup",
|
|
259
|
+
"taskId",
|
|
260
|
+
"taskKey",
|
|
261
|
+
"taskType",
|
|
262
|
+
"tddEvidence",
|
|
263
|
+
"tddExemption",
|
|
264
|
+
"terminal",
|
|
265
|
+
"toComponentId",
|
|
266
|
+
"toTaskKey",
|
|
267
|
+
"url",
|
|
268
|
+
"verdictTokenQuote",
|
|
269
|
+
"verifier",
|
|
270
|
+
"version",
|
|
271
|
+
"worker",
|
|
272
|
+
"workerRole",
|
|
273
|
+
"worktreePath",
|
|
274
|
+
"writeBoundary",
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class UnclassifiedKeys(ValueError):
|
|
279
|
+
"""A schema string field belongs to neither set."""
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# A key name says what a field is *for*; these say what one value actually is.
|
|
283
|
+
# `evidence` is the largest prose bucket in a real report and also holds bare
|
|
284
|
+
# `src/server.ts:33` citations — the key cannot separate them, the value can.
|
|
285
|
+
_MACHINE_CHARS = re.compile(r"^[A-Za-z0-9_.:@/\\#-]+$")
|
|
286
|
+
_LOWER_TOKEN = re.compile(r"^[a-z][a-z0-9]*([-_][a-z0-9]+)*$")
|
|
287
|
+
_FILE_EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,6}$")
|
|
288
|
+
_LINE_NUMBER = re.compile(r":\d+$")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def is_translatable_value(text: str) -> bool:
|
|
292
|
+
"""Whether one value is prose rather than a citation or a token.
|
|
293
|
+
|
|
294
|
+
Anything with whitespace is prose. A single run of ASCII path characters
|
|
295
|
+
is not, once it carries a directory separator, a file extension, or a line
|
|
296
|
+
number — and neither is a bare lowercase token, which is how an enum reads.
|
|
297
|
+
"""
|
|
298
|
+
value = text.strip()
|
|
299
|
+
if not value:
|
|
300
|
+
return False
|
|
301
|
+
if any(char.isspace() for char in value):
|
|
302
|
+
return True
|
|
303
|
+
if not _MACHINE_CHARS.match(value):
|
|
304
|
+
return True
|
|
305
|
+
if "/" in value or "\\" in value:
|
|
306
|
+
return False
|
|
307
|
+
if _FILE_EXTENSION.search(value) or _LINE_NUMBER.search(value):
|
|
308
|
+
return False
|
|
309
|
+
return _LOWER_TOKEN.match(value) is None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def escape_token(token: str) -> str:
|
|
313
|
+
"""Escape one JSON Pointer reference token (RFC 6901)."""
|
|
314
|
+
return token.replace("~", "~0").replace("/", "~1")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def unescape_token(token: str) -> str:
|
|
318
|
+
return token.replace("~1", "/").replace("~0", "~")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _walk(node: Any, pointer: str, key: str) -> Iterator[tuple[str, str]]:
|
|
322
|
+
if isinstance(node, Mapping):
|
|
323
|
+
for child_key, value in node.items():
|
|
324
|
+
yield from _walk(value, f"{pointer}/{escape_token(str(child_key))}", str(child_key))
|
|
325
|
+
elif isinstance(node, (list, tuple)):
|
|
326
|
+
for index, value in enumerate(node):
|
|
327
|
+
# An array element inherits the key that named the array, so
|
|
328
|
+
# `evidence: [...]` translates each entry.
|
|
329
|
+
yield from _walk(value, f"{pointer}/{index}", key)
|
|
330
|
+
elif isinstance(node, str) and key in PROSE_KEYS and is_translatable_value(node):
|
|
331
|
+
yield pointer, node
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def extract(data: Mapping[str, Any]) -> dict[str, str]:
|
|
335
|
+
"""Map every translatable pointer to the English text at it.
|
|
336
|
+
|
|
337
|
+
The translator fills in this map rather than authoring pointers, so a
|
|
338
|
+
sidecar cannot cite a path the document does not have.
|
|
339
|
+
"""
|
|
340
|
+
return dict(_walk(data, "", ""))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class OverlayReport(NamedTuple):
|
|
344
|
+
applied: int
|
|
345
|
+
# Pointers the extractor offered that the sidecar left untranslated. They
|
|
346
|
+
# render in English — a partial translation is a readable page, not a
|
|
347
|
+
# failure — but a silent fallback is how a half-empty sidecar ships
|
|
348
|
+
# unnoticed, so the count is reported.
|
|
349
|
+
untranslated: tuple[str, ...]
|
|
350
|
+
# Pointers the sidecar carries that do not resolve to a string here. A
|
|
351
|
+
# sidecar written against a different report.
|
|
352
|
+
unresolved: tuple[str, ...]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _resolve_parent(data: Any, pointer: str) -> tuple[Any, str | int] | None:
|
|
356
|
+
tokens = [unescape_token(t) for t in pointer.split("/")[1:]]
|
|
357
|
+
if not tokens:
|
|
358
|
+
return None
|
|
359
|
+
node = data
|
|
360
|
+
for token in tokens[:-1]:
|
|
361
|
+
if isinstance(node, Mapping) and token in node:
|
|
362
|
+
node = node[token]
|
|
363
|
+
elif isinstance(node, list) and token.isdigit() and int(token) < len(node):
|
|
364
|
+
node = node[int(token)]
|
|
365
|
+
else:
|
|
366
|
+
return None
|
|
367
|
+
last = tokens[-1]
|
|
368
|
+
if isinstance(node, Mapping) and isinstance(node.get(last), str):
|
|
369
|
+
return node, last
|
|
370
|
+
if isinstance(node, list) and last.isdigit() and int(last) < len(node):
|
|
371
|
+
return (node, int(last)) if isinstance(node[int(last)], str) else None
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def overlay(
|
|
376
|
+
data: Mapping[str, Any], strings: Mapping[str, str]
|
|
377
|
+
) -> tuple[dict[str, Any], OverlayReport]:
|
|
378
|
+
"""Return a deep copy of ``data`` with the sidecar's strings substituted."""
|
|
379
|
+
import copy
|
|
380
|
+
|
|
381
|
+
out = copy.deepcopy(dict(data))
|
|
382
|
+
applied = 0
|
|
383
|
+
unresolved: list[str] = []
|
|
384
|
+
for pointer, text in strings.items():
|
|
385
|
+
target = _resolve_parent(out, pointer)
|
|
386
|
+
if target is None or not isinstance(text, str) or not text:
|
|
387
|
+
unresolved.append(pointer)
|
|
388
|
+
continue
|
|
389
|
+
parent, key = target
|
|
390
|
+
parent[key] = text
|
|
391
|
+
applied += 1
|
|
392
|
+
untranslated = tuple(sorted(set(extract(data)) - set(strings)))
|
|
393
|
+
return out, OverlayReport(applied, untranslated, tuple(sorted(unresolved)))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _schema_string_keys(schema: Mapping[str, Any], *, enums: bool) -> set[str]:
|
|
397
|
+
found: set[str] = set()
|
|
398
|
+
|
|
399
|
+
def walk(node: Any) -> None:
|
|
400
|
+
if not isinstance(node, Mapping):
|
|
401
|
+
return
|
|
402
|
+
if node.get("type") == "object":
|
|
403
|
+
for key, child in (node.get("properties") or {}).items():
|
|
404
|
+
if isinstance(child, Mapping) and child.get("type") == "string":
|
|
405
|
+
if ("enum" in child) == enums:
|
|
406
|
+
found.add(str(key))
|
|
407
|
+
elif isinstance(child, Mapping) and enums and "enum" in child:
|
|
408
|
+
# An enum with no declared type is still an enum.
|
|
409
|
+
found.add(str(key))
|
|
410
|
+
walk(child)
|
|
411
|
+
elif node.get("type") == "array":
|
|
412
|
+
walk(node.get("items") or {})
|
|
413
|
+
for combinator in ("oneOf", "anyOf", "allOf"):
|
|
414
|
+
for branch in node.get(combinator) or []:
|
|
415
|
+
walk(branch)
|
|
416
|
+
|
|
417
|
+
for definition in (schema.get("$defs") or {}).values():
|
|
418
|
+
walk(definition)
|
|
419
|
+
walk(schema)
|
|
420
|
+
return found
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def schema_string_keys(schema: Mapping[str, Any]) -> set[str]:
|
|
424
|
+
"""Every leaf key in the schema whose value is a free-form string.
|
|
425
|
+
|
|
426
|
+
The drift guard compares this against the two sets above, so adding a
|
|
427
|
+
report field forces a decision about whether a translator may touch it.
|
|
428
|
+
"""
|
|
429
|
+
return _schema_string_keys(schema, enums=False)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def schema_enum_keys(schema: Mapping[str, Any]) -> set[str]:
|
|
433
|
+
"""Every leaf key the schema constrains to an enum, anywhere.
|
|
434
|
+
|
|
435
|
+
A name can be an enum in one definition and free prose in another —
|
|
436
|
+
`risk` is `high|medium|low` on a hotspot and a sentence on a risk row.
|
|
437
|
+
The renderer reaches for the same name either way (`tone-{{ row.risk }}`),
|
|
438
|
+
so one enum occurrence makes the name structural everywhere.
|
|
439
|
+
"""
|
|
440
|
+
return _schema_string_keys(schema, enums=True)
|