Compare commits

...

3 commits

Author SHA1 Message Date
0a2b8c77b8 fix(update): identify source archives and exclude dev metadata
- Embed the archived commit SHA to prevent false first-launch updates
- Exclude editor and agent directories from source and update archives
- Add coverage for archive metadata and installer filtering
2026-07-24 12:18:18 -05:00
a371223189 fix(gui): populate engine dropdown without environment config
- Defer engine imports until translation starts
- Add regression coverage for source downloads without environment settings
2026-07-24 12:12:10 -05:00
3b13575a74 chore(agents): replace Cursor rule with tool-agnostic skill
- Preserve shipped-data tracking and validation guidance
- Move repository instructions into the shared agent skill format
2026-07-22 17:27:14 -05:00
9 changed files with 286 additions and 93 deletions

View file

@ -0,0 +1,29 @@
---
name: shipped-data-assets
description: Keep shipped data assets present in git branch archives and tool updates. Use when adding or changing defaults under data/, editing their .gitignore rules, changing the update installer, or updating bundled-data coverage in tests/test_bundled_updates.py.
---
# Shipped Data Assets
Tool auto-update downloads a git branch archive. Files matched by `.gitignore` are omitted from that archive and never reach users.
## Maintain shipped defaults
- Add a `.gitignore` negation for every shipped path under `data/`, following existing patterns such as:
- `!data/skills/**`
- `!data/help/**`
- `!data/vocab_base.txt`
- `!data/translation_contexts.json`
- Do not rely on `UpdateThread.should_install` alone; ignored files never appear in the downloaded archive.
- Extend `_SHIPPED_DATA_FILES` or `ShippedDataTrackingTests` in `tests/test_bundled_updates.py` when adding a shipped path.
- Keep user-local files ignored and protected, including `data/vocab.txt`, `data/last_update_sha.txt`, `data/wolf_*.json`, and `data/api_keys.json`.
## Verify tracking
Run `git check-ignore -- <shipped-path>` for each affected file. A shipped path must exit with status 1, indicating that it is not ignored.
Run the bundled-update tests after changing shipped paths, ignore rules, or installer behavior:
```bash
python -m unittest tests.test_bundled_updates.ShippedDataTrackingTests
```

View file

@ -1,27 +0,0 @@
---
description: Shipped data/ assets must be git-trackable so tool updates include them
globs: "{.gitignore,data/**,gui/guide_tab.py,gui/main.py,tests/test_bundled_updates.py}"
alwaysApply: false
---
# Shipped data/ and tool updates
Tool auto-update downloads a **git branch archive**.
Anything matched by `.gitignore` is omitted from that archive and never reaches users.
## When adding or changing shipped defaults under `data/`
- Add a negation under `.gitignore` (same pattern as skills/help):
- `!data/skills/**`
- `!data/help/**`
- `!data/vocab_base.txt`
- `!data/translation_contexts.json`
- Do **not** rely on `UpdateThread.should_install` alone - ignored files never appear in the zip.
- Extend `_SHIPPED_DATA_FILES` / `ShippedDataTrackingTests` in `tests/test_bundled_updates.py` when adding a new shipped path.
- User-local files stay ignored/protected: `data/vocab.txt`, `data/last_update_sha.txt`, `data/wolf_*.json`, `data/api_keys.json`.
## Quick check
```bash
git check-ignore -- data/help/index.json # must exit 1 (not ignored)
```

1
.git_archival.txt Normal file
View file

@ -0,0 +1 @@
node: $Format:%H$

12
.gitattributes vendored
View file

@ -1,2 +1,14 @@
# Cmd.exe parses LF-only .bat files incorrectly on Windows (joined tokens / mangled commands). # Cmd.exe parses LF-only .bat files incorrectly on Windows (joined tokens / mangled commands).
*.bat text eol=crlf *.bat text eol=crlf
# Keep development-only agent/editor metadata out of user-facing source and
# update archives.
.agents export-ignore
.codex export-ignore
.cursor export-ignore
.github export-ignore
.vscode export-ignore
# Expand the exact archived commit so a fresh source download can identify the
# version it already contains without committing a self-referential literal SHA.
.git_archival.txt export-subst

1
.gitignore vendored
View file

@ -19,6 +19,7 @@ __pycache__
!assets/*.png !assets/*.png
!assets/engine_icons/** !assets/engine_icons/**
!.pre-commit-config.yaml !.pre-commit-config.yaml
!.git_archival.txt
!data/skills/** !data/skills/**
!data/help/** !data/help/**
!data/vocab_base.txt !data/vocab_base.txt

View file

@ -53,8 +53,7 @@ def check_tool_update() -> str | None:
"""Return latest commit SHA when a tool update is available, else None.""" """Return latest commit SHA when a tool update is available, else None."""
try: try:
latest_sha = UpdateThread.fetch_latest_sha() latest_sha = UpdateThread.fetch_latest_sha()
sha_path = Path(UpdateThread.SHA_FILE) current_sha = UpdateThread.read_installed_sha()
current_sha = sha_path.read_text().strip() if sha_path.is_file() else ""
if latest_sha != current_sha: if latest_sha != current_sha:
return latest_sha return latest_sha
except Exception: except Exception:
@ -82,9 +81,21 @@ class UpdateThread(QThread):
# "dazedtl"). Resolve dynamically so a rename cannot silently no-op again. # "dazedtl"). Resolve dynamically so a rename cannot silently no-op again.
ARCHIVE_ROOT = "dazedtl" ARCHIVE_ROOT = "dazedtl"
SHA_FILE = str(LAST_UPDATE_SHA_PATH) SHA_FILE = str(LAST_UPDATE_SHA_PATH)
ARCHIVE_SHA_FILE = str(PROJECT_ROOT / ".git_archival.txt")
# Top-level paths that should never be touched during update # Top-level paths that should never be touched during update
PROTECTED_TOP = {".env", "venv", "log", "files", "translated"} PROTECTED_TOP = {
".agents",
".codex",
".cursor",
".env",
".github",
".vscode",
"venv",
"log",
"files",
"translated",
}
# User-local files under data/ that must not be overwritten. # User-local files under data/ that must not be overwritten.
# Shipped defaults (translation_contexts.json, skills/*.md, help/*, vocab_base.txt, …) # Shipped defaults (translation_contexts.json, skills/*.md, help/*, vocab_base.txt, …)
@ -185,14 +196,45 @@ class UpdateThread(QThread):
with urllib.request.urlopen(req, timeout=15) as resp: with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())["commit"]["id"] return json.loads(resp.read())["commit"]["id"]
@staticmethod
def _read_archive_sha(path: Path) -> str:
"""Read an expanded git-archive commit ID, or return an empty string."""
if not path.is_file():
return ""
try:
for line in path.read_text(encoding="utf-8").splitlines():
key, separator, value = line.partition(":")
candidate = value.strip()
if (
separator
and key.strip() == "node"
and re.fullmatch(r"[0-9a-fA-F]{40,64}", candidate)
):
return candidate
except OSError:
pass
return ""
@classmethod
def read_installed_sha(cls) -> str:
"""Return the updated SHA, falling back to source-archive metadata."""
runtime_path = Path(cls.SHA_FILE)
if runtime_path.is_file():
try:
runtime_sha = runtime_path.read_text(encoding="utf-8").strip()
if runtime_sha:
return runtime_sha
except OSError:
pass
return cls._read_archive_sha(Path(cls.ARCHIVE_SHA_FILE))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _fetch_latest_sha(self): def _fetch_latest_sha(self):
return self.fetch_latest_sha() return self.fetch_latest_sha()
def _read_stored_sha(self): def _read_stored_sha(self):
p = Path(self.SHA_FILE) return self.read_installed_sha()
return p.read_text().strip() if p.exists() else ""
def _download_archive(self, zip_path: Path): def _download_archive(self, zip_path: Path):
req = urllib.request.Request( req = urllib.request.Request(
@ -493,10 +535,7 @@ class UpdateDialog(QDialog):
@classmethod @classmethod
def _installed_sha_display(cls) -> str: def _installed_sha_display(cls) -> str:
sha_path = Path(UpdateThread.SHA_FILE) return cls._short_sha(UpdateThread.read_installed_sha())
if sha_path.is_file():
return cls._short_sha(sha_path.read_text().strip())
return "Not recorded"
def _set_stage_pills(self, active_stage: str | None = None): def _set_stage_pills(self, active_stage: str | None = None):
stage_order = list(UpdateThread.STAGES) stage_order = list(UpdateThread.STAGES)

View file

@ -19,6 +19,7 @@ import traceback
import signal import signal
import multiprocessing import multiprocessing
import re import re
from importlib import import_module
from colorama import Fore from colorama import Fore
from tqdm import tqdm from tqdm import tqdm
from dotenv import load_dotenv from dotenv import load_dotenv
@ -75,6 +76,39 @@ BATCH_COLLECT_LIVE_CHARGE_NOTE = (
) )
TRANSLATION_MODULE_SPECS = (
("RPG Maker MV/MZ", (".json",), "modules.rpgmakermvmz", "handleMVMZ"),
("CSV", (".csv",), "modules.csv", "handleCSV"),
("Tyrano", (".ks",), "modules.tyrano", "handleTyrano"),
("Kirikiri", (".ks",), "modules.kirikiri", "handleKirikiri"),
("JSON", (".json",), "modules.json", "handleJSON"),
("Lune", (".l",), "modules.lune", "handleLune"),
("Yuris", (".json",), "modules.yuris", "handleYuris"),
("NScript", (".nscript",), "modules.nscript", "handleOnscripter"),
("Wolf RPG (WolfDawn)", (".json",), "modules.wolfdawn", "handleWolfDawn"),
("Wolf RPG", (".json",), "modules.wolf", "handleWOLF"),
("Wolf RPG 2", (".txt",), "modules.wolf2", "handleWOLF2"),
("Regex", (".txt", ".json", ".script", ".csv"), "modules.regex", "handleRegex"),
("Text", (".txt", ".srt"), "modules.text", "handleText"),
("RenPy", (".rpy",), "modules.renpy", "handleRenpy"),
("Unity", (".unity",), "modules.unity", "handleUnity"),
("Images", (".png", ".jpg", ".jpeg"), "modules.images", "handleImages"),
("RPG Maker Plugin", (".js",), "modules.rpgmakerplugin", "handlePlugin"),
("Aquedi4 Prepared JSON", (".json",), "modules.aquedi4", "handleAquedi4"),
("SRPG Studio", (".json",), "modules.srpg", "handleSRPG"),
)
def _lazy_module_handler(module_name, handler_name):
"""Return a handler that imports its engine only when translation starts."""
def run(*args, **kwargs):
module = import_module(module_name)
return getattr(module, handler_name)(*args, **kwargs)
return run
class _ShimLabel: class _ShimLabel:
"""Plain stand-in for QLabel used by Files-tab row helpers (no real Qt widget).""" """Plain stand-in for QLabel used by Files-tab row helpers (no real Qt widget)."""
@ -1647,63 +1681,25 @@ class TranslationTab(QWidget):
def setup_module_list(self): def setup_module_list(self):
"""Set up the module selection list.""" """Set up the module selection list."""
# Import modules to get the list # Engine modules read translation settings during import. A downloaded
try: # source archive intentionally has no .env yet, so importing every
sys.path.append(str(self.project_root)) # engine here used to abort discovery and leave only the fallback item.
from modules.rpgmakermvmz import handleMVMZ # Keep the UI registry independent and defer imports until a handler is
from modules.csv import handleCSV # actually used.
from modules.tyrano import handleTyrano self.modules = [
from modules.kirikiri import handleKirikiri [
from modules.json import handleJSON display_name,
from modules.lune import handleLune list(extensions),
from modules.yuris import handleYuris _lazy_module_handler(module_name, handler_name),
from modules.nscript import handleOnscripter
from modules.wolf import handleWOLF
from modules.wolf2 import handleWOLF2
from modules.wolfdawn import handleWolfDawn
from modules.regex import handleRegex
from modules.text import handleText
from modules.renpy import handleRenpy
from modules.unity import handleUnity
from modules.images import handleImages
from modules.rpgmakerplugin import handlePlugin
from modules.aquedi4 import handleAquedi4
from modules.srpg import handleSRPG
self.modules = [
["RPG Maker MV/MZ", [".json"], handleMVMZ],
["CSV", [".csv"], handleCSV],
["Tyrano", [".ks"], handleTyrano],
["Kirikiri", [".ks"], handleKirikiri],
["JSON", [".json"], handleJSON],
["Lune", [".l"], handleLune],
["Yuris", [".json"], handleYuris],
["NScript", [".nscript"], handleOnscripter],
["Wolf RPG (WolfDawn)", [".json"], handleWolfDawn],
["Wolf RPG", [".json"], handleWOLF],
["Wolf RPG 2", [".txt"], handleWOLF2],
["Regex", [".txt", ".json", ".script", ".csv"], handleRegex],
["Text", [".txt", ".srt"], handleText],
["RenPy", [".rpy"], handleRenpy],
["Unity", [".unity"], handleUnity],
["Images", [".png", ".jpg", ".jpeg"], handleImages],
["RPG Maker Plugin", [".js"], handlePlugin],
["Aquedi4 Prepared JSON", [".json"], handleAquedi4],
["SRPG Studio", [".json"], handleSRPG],
] ]
for display_name, extensions, module_name, handler_name
for module in self.modules: in TRANSLATION_MODULE_SPECS
extensions = ", ".join(module[1]) ]
self.module_combo.addItem(f"{module[0]} ({extensions})")
if self.module_combo.count(): for module in self.modules:
self._on_module_changed(self.module_combo.currentText()) extensions = ", ".join(module[1])
self.module_combo.addItem(f"{module[0]} ({extensions})")
except Exception as e: if self.module_combo.count():
# Store error for later logging since log_display might not exist yet
self.module_load_error = f"Warning: Could not load modules: {str(e)}"
# Add a default option
self.module_combo.addItem("RPG Maker MV/MZ (.json)")
self.modules = [["RPG Maker MV/MZ", [".json"], None]]
self._on_module_changed(self.module_combo.currentText()) self._on_module_changed(self.module_combo.currentText())
def _on_module_changed(self, text: str): def _on_module_changed(self, text: str):

View file

@ -37,6 +37,7 @@ class UpdateThreadInstallFilterTests(unittest.TestCase):
for rel in ( for rel in (
*_SHIPPED_DATA_FILES, *_SHIPPED_DATA_FILES,
".git_archival.txt",
"gui/main.py", "gui/main.py",
"gameupdate/GameUpdate.bat", "gameupdate/GameUpdate.bat",
"gameupdate/gameupdate/patch.ps1", "gameupdate/gameupdate/patch.ps1",
@ -58,6 +59,11 @@ class UpdateThreadInstallFilterTests(unittest.TestCase):
"log/translations.txt", "log/translations.txt",
"files/Map001.json", "files/Map001.json",
"translated/Map001.json", "translated/Map001.json",
".agents/skills/shipped-data-assets/SKILL.md",
".codex/config.toml",
".cursor/rules/project.mdc",
".github/workflows/tests.yml",
".vscode/settings.json",
): ):
with self.subTest(rel=rel): with self.subTest(rel=rel):
self.assertFalse(UpdateThread.should_install(Path(rel))) self.assertFalse(UpdateThread.should_install(Path(rel)))
@ -180,8 +186,49 @@ class ShippedDataTrackingTests(unittest.TestCase):
) )
class SourceArchiveMetadataTests(unittest.TestCase):
"""Source archives identify their commit and omit development metadata."""
def test_archival_info_is_configured_for_sha_expansion(self):
archival_info = _REPO_ROOT / ".git_archival.txt"
self.assertTrue(archival_info.is_file())
self.assertIn("$Format:%H$", archival_info.read_text(encoding="utf-8"))
ignored = subprocess.run(
["git", "check-ignore", "--", ".git_archival.txt"],
cwd=_REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(ignored.returncode, 0)
result = subprocess.run(
["git", "check-attr", "export-subst", "--", ".git_archival.txt"],
cwd=_REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0)
self.assertIn("export-subst: set", result.stdout)
def test_development_directories_are_export_ignored(self):
for rel in (".agents", ".codex", ".cursor", ".github", ".vscode"):
with self.subTest(rel=rel):
result = subprocess.run(
["git", "check-attr", "export-ignore", "--", rel],
cwd=_REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0)
self.assertIn("export-ignore: set", result.stdout)
class CheckToolUpdateTests(unittest.TestCase): class CheckToolUpdateTests(unittest.TestCase):
"""Tool update check compares remote SHA to data/last_update_sha.txt only.""" """Tool update checks runtime state, then source-archive commit metadata."""
def test_returns_sha_when_remote_differs(self): def test_returns_sha_when_remote_differs(self):
with tempfile.TemporaryDirectory() as raw: with tempfile.TemporaryDirectory() as raw:
@ -207,6 +254,43 @@ class CheckToolUpdateTests(unittest.TestCase):
): ):
self.assertIsNone(check_tool_update()) self.assertIsNone(check_tool_update())
def test_archive_sha_prevents_false_update_on_first_launch(self):
archived_sha = "a" * 40
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
sha_file = base / "missing-last-update-sha.txt"
archive_file = base / ".git_archival.txt"
archive_file.write_text(f"node: {archived_sha}\n", encoding="utf-8")
from gui.main import UpdateThread, check_tool_update
with patch.multiple(
UpdateThread,
SHA_FILE=str(sha_file),
ARCHIVE_SHA_FILE=str(archive_file),
):
with patch.object(
UpdateThread, "fetch_latest_sha", return_value=archived_sha
):
self.assertIsNone(check_tool_update())
def test_unexpanded_archive_sha_is_ignored(self):
latest_sha = "b" * 40
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
archive_file = base / ".git_archival.txt"
archive_file.write_text("node: $Format:%H$\n", encoding="utf-8")
from gui.main import UpdateThread, check_tool_update
with patch.multiple(
UpdateThread,
SHA_FILE=str(base / "missing-last-update-sha.txt"),
ARCHIVE_SHA_FILE=str(archive_file),
):
with patch.object(
UpdateThread, "fetch_latest_sha", return_value=latest_sha
):
self.assertEqual(check_tool_update(), latest_sha)
def test_returns_none_on_fetch_failure(self): def test_returns_none_on_fetch_failure(self):
from gui.main import UpdateThread, check_tool_update from gui.main import UpdateThread, check_tool_update

View file

@ -0,0 +1,58 @@
"""Regression tests for Translation-tab engine discovery."""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
try:
from PyQt5.QtWidgets import QApplication
from gui.translation_tab import TRANSLATION_MODULE_SPECS, TranslationTab
_HAS_QT = True
except Exception: # pragma: no cover - PyQt5 not installed
_HAS_QT = False
@unittest.skipUnless(_HAS_QT, "PyQt5 not available")
class TranslationEngineDropdownTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls._app = QApplication.instance() or QApplication([])
def test_all_engines_are_listed_without_environment_configuration(self) -> None:
required_settings = (
"model",
"language",
"timeout",
"width",
"listWidth",
"noteWidth",
)
without_settings = {
key: value
for key, value in os.environ.items()
if key not in required_settings
}
with patch.dict(os.environ, without_settings, clear=True):
tab = TranslationTab()
expected = [
f"{name} ({', '.join(extensions)})"
for name, extensions, _module, _handler in TRANSLATION_MODULE_SPECS
]
actual = [
tab.module_combo.itemText(index)
for index in range(tab.module_combo.count())
]
self.assertEqual(actual, expected)
self.assertTrue(all(callable(module[2]) for module in tab.modules))
if __name__ == "__main__":
unittest.main()