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.
Files changed (53) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +1 -1
  3. package/docs/project-structure-overview.md +1 -1
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/agents/workers/translator-worker.md +67 -0
  7. package/runtime/bin/okstra-render-final-report.py +0 -11
  8. package/runtime/bin/okstra-report-translate.py +158 -0
  9. package/runtime/prompts/lead/adapters/claude-code.md +1 -1
  10. package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
  11. package/runtime/prompts/lead/report-writer.md +14 -13
  12. package/runtime/prompts/lead/team-contract.md +2 -2
  13. package/runtime/prompts/wizard/prompts.ko.json +17 -1
  14. package/runtime/python/okstra_ctl/analysis_inputs.py +24 -9
  15. package/runtime/python/okstra_ctl/analysis_packet.py +23 -1
  16. package/runtime/python/okstra_ctl/clarification_items.py +241 -44
  17. package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
  18. package/runtime/python/okstra_ctl/dispatch_core.py +2 -2
  19. package/runtime/python/okstra_ctl/dispatch_state.py +12 -1
  20. package/runtime/python/okstra_ctl/final_report_paths.py +22 -1
  21. package/runtime/python/okstra_ctl/i18n.py +12 -7
  22. package/runtime/python/okstra_ctl/render_final_report.py +18 -17
  23. package/runtime/python/okstra_ctl/report_html/filters.py +15 -77
  24. package/runtime/python/okstra_ctl/report_html/render.py +44 -2
  25. package/runtime/python/okstra_ctl/report_translation.py +440 -0
  26. package/runtime/python/okstra_ctl/report_views.py +23 -9
  27. package/runtime/python/okstra_ctl/run.py +1 -1
  28. package/runtime/python/okstra_ctl/user_response.py +11 -6
  29. package/runtime/python/okstra_ctl/wizard.py +100 -25
  30. package/runtime/python/okstra_ctl/worker_liveness.py +130 -36
  31. package/runtime/templates/reports/html/base.template.html +12 -12
  32. package/runtime/templates/reports/html/i18n/en.json +395 -0
  33. package/runtime/templates/reports/html/i18n/ko.json +395 -0
  34. package/runtime/templates/reports/html/macros/forms.html +16 -16
  35. package/runtime/templates/reports/html/macros/visualizations.html +2 -2
  36. package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +17 -17
  37. package/runtime/templates/reports/html/tasks/error-analysis.template.html +12 -12
  38. package/runtime/templates/reports/html/tasks/feature-analysis.template.html +16 -16
  39. package/runtime/templates/reports/html/tasks/final-verification.template.html +12 -12
  40. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +37 -37
  41. package/runtime/templates/reports/html/tasks/implementation.template.html +18 -18
  42. package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +7 -7
  43. package/runtime/templates/reports/html/tasks/project-analysis.template.html +29 -29
  44. package/runtime/templates/reports/html/tasks/release-handoff.template.html +13 -13
  45. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +14 -14
  46. package/runtime/templates/reports/report.js +8 -5
  47. package/runtime/validators/validate-report-views.py +1 -1
  48. package/runtime/validators/validate-run.py +28 -31
  49. package/src/cli-registry.mjs +11 -0
  50. package/src/commands/inspect/worker-liveness.mjs +9 -7
  51. package/src/commands/report/translate.mjs +31 -0
  52. package/src/lib/helper-scripts.mjs +1 -0
  53. 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
- # 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:
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 ENUM_LABELS.get(vocabulary, {}).get(raw, raw)
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
- Built from the same two tables the cells render from, so a legend cannot
180
- drift from the labels it explains.
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 = ENUM_LABELS.get(vocabulary, {})
183
- return [(labels.get(value, value), hint) for value, hint in ENUM_HINTS.get(vocabulary, {}).items()]
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)