This commit is contained in:
DazedAnon 2026-07-09 07:55:19 -05:00
parent eca44a15bd
commit 519b09fad2
6 changed files with 889 additions and 75 deletions

View file

@ -14,20 +14,20 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
Step 4 Database - discover content layout; translate foundation DB sheets Step 4 Database - discover content layout; translate foundation DB sheets
(items, skills, descriptions) before narrative custom sheets (items, skills, descriptions) before narrative custom sheets
Step 5 Maps/Events - .mps maps, CommonEvent, Game.dat, Evtext; speaker handling Step 5 Maps/Events - .mps maps, CommonEvent, Game.dat, Evtext; speaker handling
Step 6 Inject - inject translations + translated names back into the Data/ binaries Step 6 Precheck - dry-run selected JSON for safety-guard skips; edit those
and refresh the git-tracked wolf_json/ with the translated lines before writing binaries
JSON; quick-inject picks just a few translated files for Step 7 Inject - inject translations into Data/ binaries and refresh
fast in-game / git iteration, or inject everything at once. wolf_json/; quick-inject picks a few files or all
Step 7 Fix wrap - search translated JSON by in-game text; Relayout Step 8 Fix wrap - search translated JSON by in-game text; Relayout
(names-wrap / cell geometry) or Manual (width + font); (names-wrap / cell geometry) or Manual (width + font);
inject the edited file inject the edited file
Step 8 Package - run from a loose Data/ folder, or repack Data.wolf Step 9 Package - run from a loose Data/ folder, or repack Data.wolf
Step 9 Saves - fix baked strings in existing .sav files (optional) Step 10 Saves - fix baked strings in existing .sav files (optional)
names.json is staged into files/ but is NOT translated in the bulk phases - WolfDawn names.json is staged into files/ but is NOT translated in the bulk phases - WolfDawn
tags each name safe / refs / verify and Phase 0 translates only safe entries tags each name safe / refs / verify and Phase 0 translates only safe entries
(per-name, not per-category), harvests them into vocab.txt, and Step 6 injects the (per-name, not per-category), harvests them into vocab.txt, and Step 7 injects the
result. All text wrapping lives in Step 7. result. All text wrapping lives in Step 8.
The extracted JSON is staged in ``<game_root>/wolf_json/`` (a manifest maps each The extracted JSON is staged in ``<game_root>/wolf_json/`` (a manifest maps each
file back to its base binary), then imported into ``files/`` for the shared file back to its base binary), then imported into ``files/`` for the shared
@ -584,10 +584,11 @@ class WolfWorkflowTab(QWidget):
("3 Names", self._build_step3_names), ("3 Names", self._build_step3_names),
("4 Database", self._build_step4_database), ("4 Database", self._build_step4_database),
("5 Maps/Events", self._build_step5_maps_events), ("5 Maps/Events", self._build_step5_maps_events),
("6 Inject", self._build_step4_inject), ("6 Precheck", self._build_step6_precheck),
("7 Fix wrap", self._build_step7_relayout), ("7 Inject", self._build_step4_inject),
("8 Package", self._build_step5_package), ("8 Fix wrap", self._build_step7_relayout),
("9 Saves", self._build_step6_saves), ("9 Package", self._build_step5_package),
("10 Saves", self._build_step6_saves),
] ]
for tab_label, builder in _tab_defs: for tab_label, builder in _tab_defs:
@ -2375,14 +2376,103 @@ class WolfWorkflowTab(QWidget):
return p return p
return None return None
# ── Step 4: Inject ───────────────────────────────────────────────────────── # ── Step 6: Precheck ───────────────────────────────────────────────────────
def _build_step6_precheck(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 6 · Inject Precheck"))
layout.addWidget(self._desc(
"Dry-run selected translated JSON before writing binaries. Lists only real "
"safety skips: control-code mismatch, or text not Shift-JIS encodable. "
"Fix those lines here, then continue to Step 7 (Inject)."
))
layout.addWidget(self._subheading("Translated files"))
self.precheck_list = QListWidget()
self.precheck_list.setMaximumHeight(200)
self.precheck_list.setStyleSheet(
"QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;"
"color:#cccccc;font-size:12px;padding:2px;}"
"QListWidget::item{padding:3px 4px;}"
"QListWidget::item:selected{background:#264f78;color:#ffffff;}"
)
layout.addWidget(self.precheck_list)
list_row = QHBoxLayout()
refresh_btn = self._register(_make_btn("↻ Refresh", "#3a3a3a"))
refresh_btn.clicked.connect(self._refresh_precheck_list)
sel_all_btn = self._register(_make_btn("Select all", "#3a3a3a"))
sel_all_btn.clicked.connect(lambda: self._set_checklist_checks(self.precheck_list, True))
sel_none_btn = self._register(_make_btn("Select none", "#3a3a3a"))
sel_none_btn.clicked.connect(lambda: self._set_checklist_checks(self.precheck_list, False))
list_row.addWidget(refresh_btn)
list_row.addWidget(sel_all_btn)
list_row.addWidget(sel_none_btn)
list_row.addStretch()
layout.addLayout(list_row)
precheck_row = QHBoxLayout()
precheck_btn = self._register(_make_btn("Precheck selected", "#007acc"))
precheck_btn.clicked.connect(self._precheck_inject_selected)
precheck_all_btn = self._register(_make_btn("Precheck all", "#007acc"))
precheck_all_btn.clicked.connect(self._precheck_inject_all)
precheck_row.addWidget(precheck_btn)
precheck_row.addWidget(precheck_all_btn)
precheck_row.addStretch()
layout.addLayout(precheck_row)
self._inject_precheck_label = QLabel("Run precheck to list safety-guard skips.")
self._inject_precheck_label.setWordWrap(True)
self._inject_precheck_label.setStyleSheet("color:#9cdcfe;font-size:12px;")
layout.addWidget(self._inject_precheck_label)
self._inject_issue_list = QListWidget()
self._inject_issue_list.setMaximumHeight(200)
self._inject_issue_list.setWordWrap(True)
self._inject_issue_list.setTextElideMode(Qt.ElideNone)
self._inject_issue_list.setUniformItemSizes(False)
self._inject_issue_list.setStyleSheet(
"QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;"
"color:#cccccc;font-size:12px;padding:2px;}"
"QListWidget::item{padding:4px;}"
"QListWidget::item:selected{background:#264f78;color:#ffffff;}"
)
self._inject_issue_list.currentItemChanged.connect(self._on_inject_issue_selected)
layout.addWidget(self._inject_issue_list)
self._inject_issue_meta = QLabel("")
self._inject_issue_meta.setWordWrap(True)
self._inject_issue_meta.setStyleSheet("color:#808080;font-size:11px;")
layout.addWidget(self._inject_issue_meta)
self._inject_issue_edit = QTextEdit()
self._inject_issue_edit.setPlaceholderText(
"Select a precheck row to edit its translated text, then Save line."
)
self._inject_issue_edit.setMaximumHeight(120)
self._inject_issue_edit.setStyleSheet(
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;border:1px solid #3c3c3c;"
"border-radius:4px;padding:6px;font-size:12px;}"
)
layout.addWidget(self._inject_issue_edit)
edit_row = QHBoxLayout()
save_line_btn = self._register(_make_btn("Save line", "#3a3a3a"))
save_line_btn.clicked.connect(self._save_inject_issue_line)
edit_row.addWidget(save_line_btn)
edit_row.addStretch()
layout.addLayout(edit_row)
self._inject_precheck_issues: list = []
self._refresh_precheck_list()
# ── Step 7: Inject ─────────────────────────────────────────────────────────
def _build_step4_inject(self, layout: QVBoxLayout): def _build_step4_inject(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 6 · Inject Translations")) layout.addWidget(_make_section_label("Step 7 · Inject Translations"))
layout.addWidget(self._desc( layout.addWidget(self._desc(
"Tick translated JSON files below, then inject them into the game's Data/ " "Tick translated JSON files below, then inject them into the game's Data/ "
"binaries. Each file is reported individually — failures are shown in a " "binaries. Run Step 6 (Precheck) first if you want to catch safety-guard "
"dialog and in the log." "skips before writing. Each file is reported individually."
)) ))
self.en_punct_cb = QCheckBox("Convert Japanese punctuation to ASCII (--en-punct)") self.en_punct_cb = QCheckBox("Convert Japanese punctuation to ASCII (--en-punct)")
@ -2424,9 +2514,9 @@ class WolfWorkflowTab(QWidget):
refresh_btn = self._register(_make_btn("↻ Refresh", "#3a3a3a")) refresh_btn = self._register(_make_btn("↻ Refresh", "#3a3a3a"))
refresh_btn.clicked.connect(self._refresh_inject_list) refresh_btn.clicked.connect(self._refresh_inject_list)
sel_all_btn = self._register(_make_btn("Select all", "#3a3a3a")) sel_all_btn = self._register(_make_btn("Select all", "#3a3a3a"))
sel_all_btn.clicked.connect(lambda: self._set_inject_checks(True)) sel_all_btn.clicked.connect(lambda: self._set_checklist_checks(self.inject_list, True))
sel_none_btn = self._register(_make_btn("Select none", "#3a3a3a")) sel_none_btn = self._register(_make_btn("Select none", "#3a3a3a"))
sel_none_btn.clicked.connect(lambda: self._set_inject_checks(False)) sel_none_btn.clicked.connect(lambda: self._set_checklist_checks(self.inject_list, False))
list_row.addWidget(refresh_btn) list_row.addWidget(refresh_btn)
list_row.addWidget(sel_all_btn) list_row.addWidget(sel_all_btn)
list_row.addWidget(sel_none_btn) list_row.addWidget(sel_none_btn)
@ -2445,14 +2535,14 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(_make_hr()) layout.addWidget(_make_hr())
layout.addWidget(self._desc( layout.addWidget(self._desc(
"English text is often longer than Japanese. Step 7 (Fix wrapping): paste " "English text is often longer than Japanese. Step 8 (Fix wrapping): paste "
"overflowing in-game text to find the sheet, wrap, and re-inject." "overflowing in-game text to find the sheet, wrap, and re-inject."
)) ))
self._refresh_inject_list() self._refresh_inject_list()
def _build_step7_relayout(self, layout: QVBoxLayout): def _build_step7_relayout(self, layout: QVBoxLayout):
"""Search-first fix wrapping: find sheet by in-game text, wrap, re-inject.""" """Search-first fix wrapping: find sheet by in-game text, wrap, re-inject."""
layout.addWidget(_make_section_label("Step 7 · Fix wrapping")) layout.addWidget(_make_section_label("Step 8 · Fix wrapping"))
layout.addWidget(self._desc( layout.addWidget(self._desc(
"Paste overflowing in-game text to find it in translated JSON. " "Paste overflowing in-game text to find it in translated JSON. "
"Relayout uses cell width + max lines (names.json → wolf names-wrap). " "Relayout uses cell width + max lines (names.json → wolf names-wrap). "
@ -3071,7 +3161,7 @@ class WolfWorkflowTab(QWidget):
else "Line already fits (manual)." else "Line already fits (manual)."
) )
self._wrap_status_label.setText( self._wrap_status_label.setText(
msg + f" Inject {self._current_wrap_path.name} from Step 6." msg + f" Inject {self._current_wrap_path.name} from Step 7."
) )
self._log(f"{'' if changed else ''} {msg}") self._log(f"{'' if changed else ''} {msg}")
self._update_wrap_preview() self._update_wrap_preview()
@ -3112,7 +3202,7 @@ class WolfWorkflowTab(QWidget):
if line is not None: if line is not None:
self._current_wrap_text = str(line.get("text") or "") self._current_wrap_text = str(line.get("text") or "")
msg = f"Wrapped line at width {width}." if changed else "Line already fits at this width." msg = f"Wrapped line at width {width}." if changed else "Line already fits at this width."
self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name} from Step 6.") self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name} from Step 7.")
self._log(f"{'' if changed else ''} {msg}") self._log(f"{'' if changed else ''} {msg}")
self._update_wrap_preview() self._update_wrap_preview()
@ -3279,7 +3369,7 @@ class WolfWorkflowTab(QWidget):
self._reload_wrap_hit_editor(hit, self._active_wrap_width()) self._reload_wrap_hit_editor(hit, self._active_wrap_width())
if not quiet and hasattr(self, "_wrap_status_label"): if not quiet and hasattr(self, "_wrap_status_label"):
self._wrap_status_label.setText( self._wrap_status_label.setText(
summary + " Re-inject names.json from Step 6." summary + " Re-inject names.json from Step 7."
) )
self._run_task(task, on_done=_after) self._run_task(task, on_done=_after)
@ -3348,8 +3438,8 @@ class WolfWorkflowTab(QWidget):
if not self._require_manifest(): if not self._require_manifest():
return return
manifest = self._read_manifest() manifest = self._read_manifest()
en_punct = self.en_punct_cb.isChecked() en_punct = self._inject_flag_en_punct()
allow_drift = self.drift_cb.isChecked() allow_drift = self._inject_flag_allow_drift()
game_json_dir = self._work_dir() game_json_dir = self._work_dir()
def task(log, progress=None): def task(log, progress=None):
@ -3412,51 +3502,209 @@ class WolfWorkflowTab(QWidget):
# Index 4 == Database: refresh discovery report and sheet list. # Index 4 == Database: refresh discovery report and sheet list.
elif idx == 4: elif idx == 4:
self._refresh_db_discovery() self._refresh_db_discovery()
# Index 6 == Inject: keep the file list in sync with translated/. # Index 6 == Precheck: keep the file list in sync with translated/.
elif idx == 6: elif idx == 6:
self._refresh_precheck_list()
# Index 7 == Inject: keep the file list in sync with translated/.
elif idx == 7:
self._refresh_inject_list() self._refresh_inject_list()
def _refresh_inject_list(self): def _fill_injectable_checklist(self, list_widget: QListWidget):
if not hasattr(self, "inject_list"): """Populate a checkable file list from translated/ + manifest."""
return list_widget.clear()
self.inject_list.clear()
if not self._read_manifest(): if not self._read_manifest():
item = QListWidgetItem("Run Step 0 (extract) first no manifest found.") item = QListWidgetItem("Run Step 0 (extract) first - no manifest found.")
item.setFlags(Qt.ItemIsEnabled) item.setFlags(Qt.ItemIsEnabled)
self.inject_list.addItem(item) list_widget.addItem(item)
return return
files = self._injectable_filenames() files = self._injectable_filenames()
if not files: if not files:
item = QListWidgetItem("No injectable files in translated/ yet.") item = QListWidgetItem("No injectable files in translated/ yet.")
item.setFlags(Qt.ItemIsEnabled) item.setFlags(Qt.ItemIsEnabled)
self.inject_list.addItem(item) list_widget.addItem(item)
return return
for json_name in files: for json_name in files:
item = QListWidgetItem(json_name) item = QListWidgetItem(json_name)
item.setData(Qt.UserRole, json_name) item.setData(Qt.UserRole, json_name)
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item.setCheckState(Qt.Unchecked) item.setCheckState(Qt.Unchecked)
self.inject_list.addItem(item) list_widget.addItem(item)
def _selected_inject_files(self) -> set[str]: def _refresh_precheck_list(self):
if not hasattr(self, "precheck_list"):
return
self._fill_injectable_checklist(self.precheck_list)
def _refresh_inject_list(self):
if not hasattr(self, "inject_list"):
return
self._fill_injectable_checklist(self.inject_list)
def _selected_checklist_files(self, list_widget: QListWidget) -> set[str]:
selected: set[str] = set() selected: set[str] = set()
for i in range(self.inject_list.count()): for i in range(list_widget.count()):
it = self.inject_list.item(i) it = list_widget.item(i)
if (it.flags() & Qt.ItemIsUserCheckable) and it.checkState() == Qt.Checked: if (it.flags() & Qt.ItemIsUserCheckable) and it.checkState() == Qt.Checked:
name = it.data(Qt.UserRole) name = it.data(Qt.UserRole)
if name: if name:
selected.add(name) selected.add(name)
return selected return selected
def _set_inject_checks(self, checked: bool): def _selected_precheck_files(self) -> set[str]:
if not hasattr(self, "inject_list"): return self._selected_checklist_files(self.precheck_list)
return
def _selected_inject_files(self) -> set[str]:
return self._selected_checklist_files(self.inject_list)
def _set_checklist_checks(self, list_widget: QListWidget, checked: bool):
state = Qt.Checked if checked else Qt.Unchecked state = Qt.Checked if checked else Qt.Unchecked
for i in range(self.inject_list.count()): for i in range(list_widget.count()):
it = self.inject_list.item(i) it = list_widget.item(i)
if it.flags() & Qt.ItemIsUserCheckable: if it.flags() & Qt.ItemIsUserCheckable:
it.setCheckState(state) it.setCheckState(state)
def _precheck_inject_selected(self):
if not self._require_manifest():
return
selected = self._selected_precheck_files()
if not selected:
QMessageBox.information(
self,
"Nothing selected",
"Tick one or more files, then click Precheck selected.",
)
return
self._run_inject_precheck(selected)
def _precheck_inject_all(self):
if not self._require_manifest():
return
files = self._injectable_filenames()
if not files:
QMessageBox.information(
self,
"Nothing to precheck",
"No injectable files found in translated/.",
)
return
self._run_inject_precheck(set(files))
def _inject_flag_en_punct(self) -> bool:
if hasattr(self, "en_punct_cb"):
return self.en_punct_cb.isChecked()
return self._setting("en_punct", "true") == "true"
def _inject_flag_allow_drift(self) -> bool:
if hasattr(self, "drift_cb"):
return self.drift_cb.isChecked()
return self._setting("allow_code_drift", "false") == "true"
def _run_inject_precheck(self, selected: set[str]):
manifest = self._read_manifest()
en_punct = self._inject_flag_en_punct()
allow_drift = self._inject_flag_allow_drift()
state: dict = {}
def task(log, progress=None):
from util.wolfdawn import inject_precheck as wolf_pre
entries = manifest["entries"]
data_dir_path = Path(manifest["data_dir"])
game_root = Path(manifest.get("root") or self._game_root)
originals_dir = game_root / WORK_DIR_NAME / "originals"
self._ensure_originals(manifest, log, progress, quiet=True)
report = wolf_pre.precheck_selected(
sorted(selected),
manifest_entries=entries,
data_dir=data_dir_path,
originals_dir=originals_dir,
translated_dir=self._tool_root() / "translated",
allow_code_drift=allow_drift,
en_punct=en_punct,
log_fn=log,
)
state["report"] = report
return True, wolf_pre.format_precheck_summary(report)
def _after(ok: bool, msg: str):
from util.wolfdawn import inject_precheck as wolf_pre
report = state.get("report")
if not report:
self._inject_precheck_label.setText(msg or "Precheck failed.")
return
self._populate_inject_precheck(report)
self._inject_precheck_label.setText(wolf_pre.format_precheck_summary(report))
if report.safety_issues:
QMessageBox.warning(
self,
"Inject precheck",
wolf_pre.format_precheck_summary(report)
+ "\n\nSelect a safety row below, fix the text, Save line, then re-run precheck.",
)
else:
QMessageBox.information(self, "Inject precheck", msg)
self._run_task(task, on_done=_after)
def _populate_inject_precheck(self, report):
from util.wolfdawn import inject_precheck as wolf_pre
self._inject_issue_list.clear()
self._inject_issue_edit.clear()
self._inject_issue_meta.setText("")
issues = wolf_pre.issues_for_ui(report)
self._inject_precheck_issues = issues
for issue in issues:
item = QListWidgetItem(issue.summary())
item.setData(Qt.UserRole, issue)
item.setForeground(QColor("#f48771"))
self._inject_issue_list.addItem(item)
fm = self._inject_issue_list.fontMetrics()
item.setSizeHint(
QSize(
self._inject_issue_list.viewport().width(),
max(fm.height() * 3 + 10, 48),
)
)
def _on_inject_issue_selected(self, current, _previous):
if current is None:
self._inject_issue_edit.clear()
self._inject_issue_meta.setText("")
return
issue = current.data(Qt.UserRole)
if issue is None:
return
meta_parts = [issue.json_file, issue.locator, issue.message]
self._inject_issue_meta.setText(" · ".join(p for p in meta_parts if p))
self._inject_issue_edit.setPlainText(issue.text or issue.source or "")
def _save_inject_issue_line(self):
item = self._inject_issue_list.currentItem()
if item is None:
QMessageBox.information(self, "Save line", "Select a precheck row first.")
return
issue = item.data(Qt.UserRole)
if issue is None:
return
from util.wolfdawn import inject_precheck as wolf_pre
new_text = self._inject_issue_edit.toPlainText()
ok, err = wolf_pre.apply_issue_text(
self._tool_root() / "translated", issue, new_text
)
if not ok:
QMessageBox.warning(self, "Save line", err or "Could not save.")
return
item.setText(issue.summary())
self._log(f"Saved precheck edit: {issue.json_file} · {issue.locator}")
QMessageBox.information(
self,
"Save line",
"Saved. Re-run Precheck to confirm the safety skip is gone.",
)
def _inject_selected(self): def _inject_selected(self):
if not self._require_manifest(): if not self._require_manifest():
return return
@ -3506,7 +3754,7 @@ class WolfWorkflowTab(QWidget):
# ── Step 5: Package ──────────────────────────────────────────────────────── # ── Step 5: Package ────────────────────────────────────────────────────────
def _build_step5_package(self, layout: QVBoxLayout): def _build_step5_package(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 8 · Package the Translated Game")) layout.addWidget(_make_section_label("Step 9 · Package the Translated Game"))
layout.addWidget(self._desc( layout.addWidget(self._desc(
"Choose how the translated build runs. A loose Data/ folder is simplest for " "Choose how the translated build runs. A loose Data/ folder is simplest for "
"playtesting; repacking rebuilds a single Data.wolf archive for distribution." "playtesting; repacking rebuilds a single Data.wolf archive for distribution."
@ -3581,10 +3829,10 @@ class WolfWorkflowTab(QWidget):
self._run_task(task) self._run_task(task)
# ── Step 6: Saves ────────────────────────────────────────────────────────── # ── Step 10: Saves ─────────────────────────────────────────────────────────
def _build_step6_saves(self, layout: QVBoxLayout): def _build_step6_saves(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 9 · Update Existing Saves (optional)")) layout.addWidget(_make_section_label("Step 10 · Update Existing Saves (optional)"))
layout.addWidget(self._desc( layout.addWidget(self._desc(
"WOLF .sav files bake in the game title and some strings at save time. This rewrites " "WOLF .sav files bake in the game title and some strings at save time. This rewrites "
"them so old Japanese saves load cleanly in the translated build. It is only needed " "them so old Japanese saves load cleanly in the translated build. It is only needed "

View file

@ -45,11 +45,14 @@ class WolfInjectCountsTests(unittest.TestCase):
"event 374 cmd 233 str 0: control-code mismatch - source has [\"\\\\^\"], " "event 374 cmd 233 str 0: control-code mismatch - source has [\"\\\\^\"], "
"translation has [\"\\\\ \"]\n" "translation has [\"\\\\ \"]\n"
"applied 24547 translation(s) (37 untranslated, 0 drifted); wrote CommonEvent.dat\n" "applied 24547 translation(s) (37 untranslated, 0 drifted); wrote CommonEvent.dat\n"
"WARNING: 1 line(s) left UNTRANSLATED by a safety guard - "
"1 control-code mismatch, 0 not encodable\n"
) )
applied, drifted = wolfdawn.parse_strings_inject_counts("", stderr) applied, drifted = wolfdawn.parse_strings_inject_counts("", stderr)
self.assertEqual(applied, 24547) self.assertEqual(applied, 24547)
self.assertEqual(drifted, 0) self.assertEqual(drifted, 0)
self.assertEqual(wolfdawn.parse_strings_inject_untranslated("", stderr), 37) self.assertEqual(wolfdawn.parse_strings_inject_untranslated("", stderr), 37)
self.assertEqual(wolfdawn.parse_strings_inject_safety_count("", stderr), 1)
mismatches = wolfdawn.parse_strings_inject_mismatches("", stderr) mismatches = wolfdawn.parse_strings_inject_mismatches("", stderr)
self.assertEqual(len(mismatches), 1) self.assertEqual(len(mismatches), 1)
self.assertIn("control-code mismatch", mismatches[0]) self.assertIn("control-code mismatch", mismatches[0])

View file

@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Unit tests for inject precheck and safety-guard parsers."""
from __future__ import annotations
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
os.chdir(ROOT)
sys.path.insert(0, str(ROOT))
from util import wolfdawn # noqa: E402
from util.wolfdawn import inject as wi # noqa: E402
from util.wolfdawn import inject_precheck as pre # noqa: E402
class InjectSafetyParserTests(unittest.TestCase):
def test_identity_count_is_not_safety(self):
stderr = (
"db type 49 row 18 field 21: control-code mismatch - source has "
'["\\\\c[19]"], translation has []; edit the words\n'
"would apply 2178 translation(s) (4 untranslated, 0 drifted); "
"dry run - did NOT write out.project\n"
"WARNING: 1 line(s) left UNTRANSLATED by a safety guard - "
"1 control-code mismatch, 0 not encodable\n"
)
self.assertEqual(wolfdawn.parse_strings_inject_counts("", stderr), (2178, 0))
self.assertEqual(wolfdawn.parse_strings_inject_untranslated("", stderr), 4)
self.assertEqual(wolfdawn.parse_strings_inject_safety_count("", stderr), 1)
lines = wolfdawn.parse_strings_inject_safety_lines("", stderr)
self.assertEqual(len(lines), 1)
self.assertEqual(lines[0][1], "code_mismatch")
def test_interpret_ignores_identity_in_summary(self):
res = mock.Mock(
ok=True,
returncode=0,
stdout="",
stderr=(
"applied 2178 translation(s) (4 untranslated, 0 drifted); "
"wrote DataBase.project\n"
),
)
result = wi._interpret_strings_result("DataBase.project.json", res)
self.assertTrue(result.success)
self.assertEqual(result.summary, "applied 2178 line(s)")
self.assertNotIn("unchanged", result.summary)
self.assertNotIn("safety", result.summary)
def test_interpret_labels_real_safety_warning(self):
res = mock.Mock(
ok=True,
returncode=0,
stdout="",
stderr=(
"db type 1 row 0 field 0: control-code mismatch - source has "
'["\\\\^"], translation has []\n'
"applied 10 translation(s) (0 untranslated, 0 drifted); wrote x\n"
"WARNING: 1 line(s) left UNTRANSLATED by a safety guard - "
"1 control-code mismatch, 0 not encodable\n"
),
)
result = wi._interpret_strings_result("x.json", res)
self.assertTrue(result.success)
self.assertIn("skipped by safety guard", result.summary)
class InjectPrecheckLocalTests(unittest.TestCase):
def test_resolve_db_locator(self):
doc = {
"kind": "db",
"groups": [
{
"type": 23,
"typeName": "Sheet",
"lines": [
{
"row": 2,
"field": 65,
"fieldName": "comment",
"source": "txtblank",
"text": "txtblank",
},
],
}
],
}
line, hit = pre.resolve_locator_line(doc, "db type 23 row 2 field 65")
self.assertIsNotNone(line)
self.assertEqual(line["text"], "txtblank")
self.assertEqual(hit["sheet_name"], "Sheet")
def test_apply_issue_text_updates_json(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "DataBase.project.json"
doc = {
"kind": "db",
"groups": [
{
"type": 1,
"typeName": "T",
"lines": [
{
"row": 0,
"field": 2,
"fieldName": "f",
"source": "\\^",
"text": "A",
}
],
}
],
}
path.write_text(json.dumps(doc), encoding="utf-8")
issue = pre.InjectIssue(
json_file="DataBase.project.json",
kind="code_mismatch",
locator="db type 1 row 0 field 2",
message="mismatch",
source="\\^",
text="A",
)
ok, err = pre.apply_issue_text(Path(tmp), issue, "A\\^")
self.assertTrue(ok, err)
saved = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(saved["groups"][0]["lines"][0]["text"], "A\\^")
if __name__ == "__main__":
unittest.main()

View file

@ -44,6 +44,8 @@ __all__ = [
"parse_strings_inject_counts", "parse_strings_inject_counts",
"parse_strings_inject_untranslated", "parse_strings_inject_untranslated",
"parse_strings_inject_mismatches", "parse_strings_inject_mismatches",
"parse_strings_inject_safety_count",
"parse_strings_inject_safety_lines",
"parse_names_inject_counts", "parse_names_inject_counts",
"inject_had_applied", "inject_had_applied",
"pack", "pack",
@ -56,20 +58,25 @@ __all__ = [
"desc_relayout", "desc_relayout",
] ]
# strings-inject: "applied N translation(s) (M drifted)"; names-inject uses "name change(s)". # strings-inject: "applied N" / "would apply N"; names-inject uses "name change(s)".
# WolfDawn counts identity lines (text == source) as "untranslated" - not safety skips.
_INJECT_COUNTS_RE = re.compile( _INJECT_COUNTS_RE = re.compile(
r"applied\s+(\d+)\s+translation.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL r"(?:would apply|applied)\s+(\d+)\s+translation.*?(\d+)\s+drifted",
re.IGNORECASE | re.DOTALL,
) )
_INJECT_UNTRANSLATED_RE = re.compile( _INJECT_UNTRANSLATED_RE = re.compile(
r"applied\s+\d+\s+translation.*?\((\d+)\s+untranslated", re.IGNORECASE | re.DOTALL r"(?:would apply|applied)\s+\d+\s+translation.*?\((\d+)\s+untranslated",
re.IGNORECASE | re.DOTALL,
) )
_INJECT_MISMATCH_RE = re.compile( _INJECT_SAFETY_COUNT_RE = re.compile(
r"^(?:event\s+\d+\s+)?cmd\s+(\d+)\s+str\s+(\d+):\s+control-code mismatch", r"WARNING:\s*(\d+)\s+line\(s\)\s+left\s+UNTRANSLATED\s+by\s+a\s+safety\s+guard",
re.IGNORECASE | re.MULTILINE, re.IGNORECASE,
) )
_INJECT_MISMATCH_EVENT_RE = re.compile( _INJECT_SAFETY_LINE_RE = re.compile(
r"^event\s+(\d+)\s+cmd\s+(\d+)\s+str\s+(\d+):\s+control-code mismatch", r"^(?P<locator>.+?):\s+"
re.IGNORECASE | re.MULTILINE, r"(?P<kind>control-code mismatch|text not representable in Shift-JIS)\b"
r"(?P<rest>.*)$",
re.IGNORECASE,
) )
_NAMES_INJECT_COUNTS_RE = re.compile( _NAMES_INJECT_COUNTS_RE = re.compile(
r"(?:would apply|applied)\s+(\d+)\s+name change.*?(\d+)\s+(?:drifted|unmatched)", r"(?:would apply|applied)\s+(\d+)\s+name change.*?(\d+)\s+(?:drifted|unmatched)",
@ -95,7 +102,7 @@ def _inject_output_text(stdout: str, stderr: str) -> str:
def parse_strings_inject_counts(stdout: str, stderr: str = "") -> tuple[int | None, int | None]: def parse_strings_inject_counts(stdout: str, stderr: str = "") -> tuple[int | None, int | None]:
"""Return (applied, drifted) from wolf strings-inject output, or (None, None).""" """Return (applied / would-apply, drifted) from wolf strings-inject output."""
text = _inject_output_text(stdout, stderr) text = _inject_output_text(stdout, stderr)
if not text: if not text:
return None, None return None, None
@ -106,23 +113,54 @@ def parse_strings_inject_counts(stdout: str, stderr: str = "") -> tuple[int | No
def parse_strings_inject_untranslated(stdout: str, stderr: str = "") -> int | None: def parse_strings_inject_untranslated(stdout: str, stderr: str = "") -> int | None:
"""Return the untranslated line count from strings-inject summary, if present.""" """Return identity-line count (``text == source``), not safety-guard skips."""
text = _inject_output_text(stdout, stderr) text = _inject_output_text(stdout, stderr)
m = _INJECT_UNTRANSLATED_RE.search(text) m = _INJECT_UNTRANSLATED_RE.search(text)
return int(m.group(1)) if m else None return int(m.group(1)) if m else None
def parse_strings_inject_mismatches(stdout: str, stderr: str = "") -> list[str]: def parse_strings_inject_safety_count(stdout: str, stderr: str = "") -> int | None:
"""Return human-readable control-code mismatch locations from inject output.""" """Return WARNING safety-guard skip count, if WolfDawn printed one."""
text = _inject_output_text(stdout, stderr) text = _inject_output_text(stdout, stderr)
out: list[str] = [] m = _INJECT_SAFETY_COUNT_RE.search(text)
for line in text.splitlines(): return int(m.group(1)) if m else None
line = line.strip()
if "control-code mismatch" in line:
out.append(line) def parse_strings_inject_safety_lines(
stdout: str, stderr: str = ""
) -> list[tuple[str, str, str]]:
"""Return ``(locator, kind, full_line)`` for each per-line safety diagnostic.
*kind* is ``code_mismatch`` or ``unrepresentable``.
"""
text = _inject_output_text(stdout, stderr)
out: list[tuple[str, str, str]] = []
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
m = _INJECT_SAFETY_LINE_RE.match(line)
if not m:
continue
kind_raw = m.group("kind").lower()
kind = (
"unrepresentable"
if "representable" in kind_raw
else "code_mismatch"
)
out.append((m.group("locator").strip(), kind, line))
return out return out
def parse_strings_inject_mismatches(stdout: str, stderr: str = "") -> list[str]:
"""Return human-readable control-code mismatch lines from inject output."""
return [
full
for _loc, kind, full in parse_strings_inject_safety_lines(stdout, stderr)
if kind == "code_mismatch"
]
def parse_names_inject_counts(stdout: str, stderr: str = "") -> tuple[int | None, int | None]: def parse_names_inject_counts(stdout: str, stderr: str = "") -> tuple[int | None, int | None]:
"""Return (applied, drifted) from wolf names-inject output, or (None, None).""" """Return (applied, drifted) from wolf names-inject output, or (None, None)."""
text = _inject_output_text(stdout, stderr) text = _inject_output_text(stdout, stderr)
@ -619,6 +657,7 @@ def strings_inject(
output: PathLike, output: PathLike,
allow_code_drift: bool = False, allow_code_drift: bool = False,
en_punct: bool = False, en_punct: bool = False,
dry_run: bool = False,
log_fn=None, log_fn=None,
) -> WolfResult: ) -> WolfResult:
"""``wolf strings-inject <edited.json> --base <orig> -o <out> [flags]``.""" """``wolf strings-inject <edited.json> --base <orig> -o <out> [flags]``."""
@ -627,6 +666,8 @@ def strings_inject(
args.append("--allow-code-drift") args.append("--allow-code-drift")
if en_punct: if en_punct:
args.append("--en-punct") args.append("--en-punct")
if dry_run:
args.append("--dry-run")
return _run(args, log_fn=log_fn) return _run(args, log_fn=log_fn)

View file

@ -127,22 +127,24 @@ def _wolf_output_snippet(stdout: str, stderr: str, *, limit: int = 400) -> str:
def _interpret_strings_result(json_name: str, res: wolfdawn.WolfResult) -> FileInjectResult: def _interpret_strings_result(json_name: str, res: wolfdawn.WolfResult) -> FileInjectResult:
cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr) cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr)
applied, drifted = wolfdawn.parse_strings_inject_counts(res.stdout, res.stderr) applied, drifted = wolfdawn.parse_strings_inject_counts(res.stdout, res.stderr)
untranslated = wolfdawn.parse_strings_inject_untranslated(res.stdout, res.stderr) safety = wolfdawn.parse_strings_inject_safety_count(res.stdout, res.stderr)
mismatches = wolfdawn.parse_strings_inject_mismatches(res.stdout, res.stderr) mismatches = wolfdawn.parse_strings_inject_mismatches(res.stdout, res.stderr)
detail = _wolf_output_snippet(res.stdout, res.stderr) detail = _wolf_output_snippet(res.stdout, res.stderr)
if not res.ok: # Exit 2 with a positive applied count still wrote the good lines; treat as success
reason = cli_err or f"wolf exited {res.returncode}" # and surface safety skips in the summary (see inject_had_applied).
return FileInjectResult(json_name, False, reason, applied=applied, detail=detail)
if wolfdawn.inject_had_applied(applied): if wolfdawn.inject_had_applied(applied):
msg = f"applied {applied} line(s)" msg = f"applied {applied} line(s)"
if drifted: if drifted:
msg += f" ({drifted} drifted)" msg += f" ({drifted} drifted)"
if untranslated: if safety:
msg += f" ({untranslated} skipped by safety guard)" msg += f" ({safety} skipped by safety guard)"
return FileInjectResult(json_name, True, msg, applied=applied, detail=detail) return FileInjectResult(json_name, True, msg, applied=applied, detail=detail)
if not res.ok:
reason = cli_err or f"wolf exited {res.returncode}"
return FileInjectResult(json_name, False, reason, applied=applied, detail=detail)
if (drifted or 0) > 0: if (drifted or 0) > 0:
return FileInjectResult( return FileInjectResult(
json_name, json_name,
@ -152,11 +154,11 @@ def _interpret_strings_result(json_name: str, res: wolfdawn.WolfResult) -> FileI
detail=detail, detail=detail,
) )
if untranslated or mismatches: if safety or mismatches:
parts = [] parts = []
if untranslated: if safety:
parts.append(f"{untranslated} line(s) skipped by safety guard") parts.append(f"{safety} line(s) skipped by safety guard")
if mismatches: elif mismatches:
parts.append(f"{len(mismatches)} control-code mismatch(es)") parts.append(f"{len(mismatches)} control-code mismatch(es)")
return FileInjectResult( return FileInjectResult(
json_name, False, "; ".join(parts), applied=0, detail=detail json_name, False, "; ".join(parts), applied=0, detail=detail

View file

@ -0,0 +1,384 @@
"""Pre-inject review: list WolfDawn safety-guard skips before writing binaries.
WolfDawn ``strings-inject`` outcomes:
* ``applied`` - translation written
* ``untranslated`` - ``text == source`` (left alone; not a problem, not listed here)
* ``drifted`` - base no longer holds ``source`` (stale Data/; file-level, not listed here)
* ``control-code mismatch`` / ``text not representable`` - safety skips (listed)
This module dry-runs selected files and resolves safety locators back into editable
JSON lines so Step 6 can show a review list before inject.
"""
from __future__ import annotations
import json
import re
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
from util.wolfdawn import codes as wolf_codes
from util.wolfdawn import db_dat_sibling
from util import wolfdawn
from util.wolfdawn.inject import NAMES_JSON, orig_base_for, repair_inject_json
_DB_LOC_RE = re.compile(
r"^db\s+type\s+(?P<type>\d+)\s+row\s+(?P<row>\d+)\s+field\s+(?P<field>\d+)\s*$",
re.IGNORECASE,
)
_EVENT_LOC_RE = re.compile(
r"^event\s+(?P<event>\d+)"
r"(?:\s+page\s+(?P<page>\d+))?"
r"\s+cmd\s+(?P<cmd>\d+)\s+str\s+(?P<str>\d+)\s*$",
re.IGNORECASE,
)
_GAMEDAT_LOC_RE = re.compile(r"^gamedat\s+(?P<key>.+)\s*$", re.IGNORECASE)
@dataclass
class InjectIssue:
"""One line skipped by a WolfDawn safety guard."""
json_file: str
kind: str # code_mismatch | unrepresentable
locator: str
message: str
source: str = ""
text: str = ""
hit_id: dict[str, Any] = field(default_factory=dict)
def summary(self) -> str:
label = {
"code_mismatch": "control-code mismatch",
"unrepresentable": "not Shift-JIS encodable",
}.get(self.kind, self.kind)
preview = (self.text or self.source).replace("\n", " ").strip()
if len(preview) > 90:
preview = preview[:90] + ""
loc = self.locator or self.json_file
if preview:
return f"{label}\n{self.json_file} · {loc}\n{preview}"
return f"{label}\n{self.json_file} · {loc}"
@dataclass
class FilePrecheck:
json_name: str
would_apply: int | None = None
drifted: int | None = None
safety_count: int | None = None
issues: list[InjectIssue] = field(default_factory=list)
error: str = ""
detail: str = ""
@dataclass
class PrecheckReport:
files: list[FilePrecheck] = field(default_factory=list)
@property
def issues(self) -> list[InjectIssue]:
out: list[InjectIssue] = []
for f in self.files:
out.extend(f.issues)
return out
@property
def safety_issues(self) -> list[InjectIssue]:
return list(self.issues)
@property
def ok(self) -> bool:
return all(not f.error for f in self.files)
def _load_doc(path: Path) -> dict[str, Any] | None:
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
except Exception:
return None
return data if isinstance(data, dict) else None
def resolve_locator_line(
doc: dict[str, Any], locator: str
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
"""Map a WolfDawn inject locator to the JSON line dict and a hit_id."""
kind = str(doc.get("kind") or "")
m = _DB_LOC_RE.match(locator.strip())
if m and kind == "db":
type_id, row, field = int(m.group("type")), int(m.group("row")), int(m.group("field"))
for g in doc.get("groups") or []:
if not isinstance(g, dict) or g.get("type") != type_id:
continue
type_name = str(g.get("typeName") or type_id)
for ln in g.get("lines") or []:
if not isinstance(ln, dict):
continue
if ln.get("row") == row and ln.get("field") == field:
return ln, {
"json_file": "",
"kind": "db",
"sheet_name": type_name,
"row": row,
"field_name": str(ln.get("fieldName") or ""),
"type": type_id,
"field": field,
}
return None, {
"kind": "db",
"type": type_id,
"row": row,
"field": field,
"sheet_name": str(type_id),
}
m = _EVENT_LOC_RE.match(locator.strip())
if m and kind in ("map", "common"):
event = int(m.group("event"))
page = m.group("page")
page_i = int(page) if page is not None else None
cmd, si_arg = int(m.group("cmd")), int(m.group("str"))
for si, sc in enumerate(doc.get("scenes") or []):
if not isinstance(sc, dict) or sc.get("event") != event:
continue
if page_i is not None and sc.get("page") != page_i:
continue
for li, ln in enumerate(sc.get("lines") or []):
if not isinstance(ln, dict):
continue
if ln.get("cmd") == cmd and ln.get("str") == si_arg:
return ln, {
"json_file": "",
"kind": kind,
"sheet_name": "",
"scene_index": si,
"line_index": li,
"event": event,
"page": page_i,
"cmd": cmd,
"str": si_arg,
}
return None, {
"kind": kind,
"event": event,
"page": page_i,
"cmd": cmd,
"str": si_arg,
}
m = _GAMEDAT_LOC_RE.match(locator.strip())
if m and kind == "gamedat":
key = m.group("key").strip()
for ln in doc.get("lines") or []:
if isinstance(ln, dict) and str(ln.get("key") or "") == key:
return ln, {
"json_file": "",
"kind": "gamedat",
"sheet_name": "Game.dat",
"field_name": key,
"key": key,
}
return None, {"kind": "gamedat", "key": key, "sheet_name": "Game.dat"}
return None, {}
def apply_issue_text(
translated_dir: Path,
issue: InjectIssue,
new_text: str,
) -> tuple[bool, str]:
"""Write *new_text* onto the JSON line for *issue*. Returns (ok, error)."""
path = Path(translated_dir) / issue.json_file
if not path.is_file():
return False, f"{issue.json_file} not found"
doc = _load_doc(path)
if not doc:
return False, f"could not read {issue.json_file}"
line: dict[str, Any] | None = None
if issue.locator:
line, _ = resolve_locator_line(doc, issue.locator)
if line is None and issue.hit_id.get("kind") == "db":
line, _ = resolve_locator_line(
doc,
f"db type {issue.hit_id.get('type')} row {issue.hit_id.get('row')} "
f"field {issue.hit_id.get('field')}",
)
if line is None:
return False, f"could not locate {issue.locator or issue.json_file}"
line["text"] = new_text
src = line.get("source")
if isinstance(src, str) and isinstance(new_text, str):
repaired = wolf_codes.rebuild_text_preserving_source_codes(src, new_text)
if repaired != new_text:
line["text"] = repaired
path.write_text(
json.dumps(doc, ensure_ascii=False, indent=4) + "\n",
encoding="utf-8",
)
issue.text = str(line.get("text") or "")
return True, ""
def _precheck_strings_file(
json_name: str,
entry: dict,
inject_src: Path,
data_dir: Path,
originals_dir: Path,
*,
allow_code_drift: bool,
en_punct: bool,
) -> FilePrecheck:
result = FilePrecheck(json_name=json_name)
orig = orig_base_for(entry, data_dir, originals_dir)
if not orig.exists():
result.error = f"no pristine original at {orig} (re-run Step 0 extract)"
return result
if entry.get("kind") == "db" and not db_dat_sibling(orig).is_file():
result.error = "missing database .dat pair in originals (re-run Step 0 extract)"
return result
doc = _load_doc(inject_src)
with tempfile.TemporaryDirectory(prefix="wolf-precheck-") as tmp:
out = Path(tmp) / Path(entry["base"]).name
res = wolfdawn.strings_inject(
str(inject_src),
str(orig),
str(out),
allow_code_drift=allow_code_drift,
en_punct=en_punct,
dry_run=True,
log_fn=None,
)
result.detail = ((res.stdout or "") + "\n" + (res.stderr or "")).strip()
applied, drifted = wolfdawn.parse_strings_inject_counts(res.stdout, res.stderr)
result.would_apply = applied
result.drifted = drifted
result.safety_count = wolfdawn.parse_strings_inject_safety_count(res.stdout, res.stderr)
cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr)
if cli_err and applied is None and not result.issues:
result.error = cli_err
return result
if doc:
for locator, kind, full in wolfdawn.parse_strings_inject_safety_lines(
res.stdout, res.stderr
):
line, hit_id = resolve_locator_line(doc, locator)
hit_id = dict(hit_id)
hit_id["json_file"] = json_name
if not hit_id.get("sheet_name"):
hit_id["sheet_name"] = json_name
src = str(line.get("source") or "") if isinstance(line, dict) else ""
txt = str(line.get("text") or "") if isinstance(line, dict) else ""
result.issues.append(
InjectIssue(
json_file=json_name,
kind=kind,
locator=locator,
message=full,
source=src,
text=txt,
hit_id=hit_id,
)
)
return result
def precheck_selected(
selected: list[str],
*,
manifest_entries: list[dict],
data_dir: Path,
originals_dir: Path,
translated_dir: Path,
allow_code_drift: bool = False,
en_punct: bool = False,
log_fn: Callable[[str], None] | None = None,
) -> PrecheckReport:
"""Dry-run inject for *selected* files and collect safety-guard issues."""
emit = log_fn or (lambda _msg: None)
report = PrecheckReport()
by_json = {e["json"]: e for e in manifest_entries if e.get("json")}
ordered = sorted(set(selected), key=lambda n: (n != NAMES_JSON, n.lower()))
for json_name in ordered:
if json_name == NAMES_JSON:
# names-inject does not emit the same per-line safety locators.
report.files.append(FilePrecheck(json_name=NAMES_JSON))
emit(f"Precheck {NAMES_JSON}: skipped (use names-inject dry-run in log if needed)")
continue
entry = by_json.get(json_name)
if not entry:
report.files.append(
FilePrecheck(
json_name,
error="not listed in manifest (re-run Step 0 extract)",
)
)
continue
src = translated_dir / json_name
if not src.is_file():
report.files.append(
FilePrecheck(json_name, error=f"not found in {translated_dir.name}/")
)
continue
emit(f"Precheck {json_name}")
inject_src = repair_inject_json(src)
fp = _precheck_strings_file(
json_name,
entry,
inject_src,
data_dir,
originals_dir,
allow_code_drift=allow_code_drift,
en_punct=en_punct,
)
if fp.error:
emit(f"{json_name}: {fp.error}")
else:
parts = []
if fp.would_apply is not None:
parts.append(f"would apply {fp.would_apply}")
if fp.issues:
parts.append(f"{len(fp.issues)} safety skip(s)")
elif fp.safety_count:
parts.append(f"{fp.safety_count} safety skip(s)")
else:
parts.append("no safety skips")
if fp.drifted:
parts.append(f"{fp.drifted} drifted")
emit(f" · {json_name}: " + ", ".join(parts))
report.files.append(fp)
return report
def format_precheck_summary(report: PrecheckReport) -> str:
"""One-line status for the Step 6 precheck label."""
safety = len(report.safety_issues)
errors = sum(1 for f in report.files if f.error)
parts = []
if safety:
parts.append(f"{safety} safety skip(s)")
if errors:
parts.append(f"{errors} file error(s)")
if not parts:
return "Precheck clean - no safety-guard skips."
return "Precheck: " + ", ".join(parts)
def issues_for_ui(report: PrecheckReport) -> list[InjectIssue]:
"""Safety-guard issues only."""
return list(report.safety_issues)