blun-king-cli 9.1.5 → 9.1.8

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 (29) hide show
  1. package/bin/launcher-mode.js +1 -0
  2. package/bin/launcher-runtime.js +60 -29
  3. package/bin/standard-tools-bootstrap.js +218 -0
  4. package/bin/update-notice.js +37 -15
  5. package/blun.mjs +4 -2
  6. package/package.json +50 -47
  7. package/standard-skills/blun-app-design-system/SKILL.md +30 -0
  8. package/standard-skills/blun-app-design-system/provenance.json +17 -0
  9. package/standard-skills/blun-app-design-system/references/tokens.md +48 -0
  10. package/standard-skills/blun-app-design-system/scripts/check-blun-design.mjs +49 -0
  11. package/standard-tools/language-guard/blun_language_guard.py +686 -0
  12. package/standard-tools/language-guard/check_diacritics.py +353 -0
  13. package/standard-tools/language-guard/guard_service_client.py +82 -0
  14. package/standard-tools/language-guard/language_quality.py +172 -0
  15. package/standard-tools/language-guard/translation_guard.py +916 -0
  16. package/standard-tools/manifest.json +76 -0
  17. package/skills/design-taste-frontend/SKILL.md +0 -1206
  18. package/skills/full-output-enforcement/SKILL.md +0 -49
  19. package/skills/high-end-visual-design/SKILL.md +0 -98
  20. package/skills/image-to-code/SKILL.md +0 -1228
  21. package/skills/industrial-brutalist-ui/SKILL.md +0 -92
  22. package/skills/minimalist-ui/SKILL.md +0 -85
  23. package/skills/motion-design-taste/SKILL.md +0 -74
  24. package/skills/premortem/SKILL.md +0 -148
  25. package/skills/redesign-existing-projects/SKILL.md +0 -178
  26. package/skills/screenshot-lesen/SKILL.md +0 -52
  27. package/skills/stitch-design-taste/DESIGN.md +0 -121
  28. package/skills/stitch-design-taste/SKILL.md +0 -184
  29. package/skills/web-lesen/SKILL.md +0 -58
@@ -0,0 +1,916 @@
1
+ #!/usr/bin/env python3
2
+ """Check that a translation preserves structure and protected tokens."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ import unicodedata
11
+ import xml.etree.ElementTree as ET
12
+ from collections import Counter
13
+ from html.parser import HTMLParser
14
+ from pathlib import Path
15
+ from typing import Any, Iterable
16
+
17
+
18
+ TOKEN_PATTERNS = (
19
+ ("URL", re.compile(r"(?:https?://|mailto:)[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+")),
20
+ ("email", re.compile(r"(?<![\w.+-])[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}")),
21
+ ("Markdown destination", re.compile(r"(?<=\]\()[^\s)]+")),
22
+ ("template", re.compile(r"\{\{[^{}]+\}\}|\$\{[^{}]+\}|%\{[^{}]+\}")),
23
+ ("printf", re.compile(r"%(?:\d+\$)?[-+#0 ']*(?:\d+|\*)?(?:\.\d+|\.\*)?[hlLjzt]*[diouxXfFeEgGaAcspn%@]")),
24
+ ("XML entity", re.compile(r"&(?:[A-Za-z][A-Za-z0-9]+|#\d+|#x[0-9A-Fa-f]+);")),
25
+ ("HTML tag", re.compile(r"</?[A-Za-z][^<>]*?>")),
26
+ ("escape", re.compile(r"\\(?:[nrtbfv\\\"']|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|x[0-9A-Fa-f]{2})")),
27
+ ("inline code", re.compile(r"(?<!`)`[^`\n]+`(?!`)")),
28
+ ("ICU argument", re.compile(r"\{\s*([A-Za-z_][\w.-]*)\s*(?=[,}])")),
29
+ ("simple placeholder", re.compile(r"\{[A-Za-z_][\w.-]*\}")),
30
+ )
31
+
32
+ FENCED_CODE = re.compile(r"```[^\n]*\n.*?```|~~~[^\n]*\n.*?~~~", re.DOTALL)
33
+ ICU_HEADER = re.compile(
34
+ r"\{\s*([A-Za-z_][\w.-]*)\s*,\s*(plural|selectordinal|select)\s*,",
35
+ re.IGNORECASE,
36
+ )
37
+ ICU_SELECTOR_AT_POSITION = re.compile(r"\s*([=\w.-]+)\s*(?=\{)")
38
+ TRANSLATABLE_HTML_ATTRIBUTES = {
39
+ "alt",
40
+ "aria-description",
41
+ "aria-label",
42
+ "placeholder",
43
+ "title",
44
+ }
45
+ HTML_CODE_ELEMENTS = {"script", "style"}
46
+ JSONLD_LINGUISTIC_KEYS = {
47
+ "alternativeHeadline", "articleBody", "caption", "description", "headline",
48
+ "keywords", "name", "text",
49
+ }
50
+ TRANSLATABLE_META_NAMES = {
51
+ "application-name",
52
+ "description",
53
+ "keywords",
54
+ "twitter:description",
55
+ "twitter:title",
56
+ }
57
+ TRANSLATABLE_META_PROPERTIES = {
58
+ "og:description",
59
+ "og:site_name",
60
+ "og:title",
61
+ "twitter:description",
62
+ "twitter:title",
63
+ }
64
+ MIN_TOTAL_SOURCE_UNITS = 80
65
+ MIN_SEGMENT_SOURCE_UNITS = 24
66
+ MIN_IDENTITY_SOURCE_CHARACTERS = 200
67
+ MIN_SEGMENT_IDENTITY_UNITS = 24
68
+ COPYRIGHT_MARKER = re.compile(
69
+ r"^(?:copyright\b|[^\n]{0,32}(?:©|\(c\)))",
70
+ re.IGNORECASE,
71
+ )
72
+
73
+
74
+ def linguistic_units(text: str) -> int:
75
+ """Count Unicode letters and numbers after excluding protected syntax."""
76
+ masked = FENCED_CODE.sub("", text)
77
+ for _, pattern in TOKEN_PATTERNS:
78
+ masked = pattern.sub("", masked)
79
+ return sum(unicodedata.category(character)[0] in {"L", "N"} for character in masked)
80
+
81
+
82
+ def canonical_identity_text(text: str) -> str:
83
+ """Normalize transport-only differences without hiding changed characters."""
84
+ return unicodedata.normalize(
85
+ "NFC",
86
+ text.lstrip("\ufeff").replace("\r\n", "\n").replace("\r", "\n"),
87
+ ).strip()
88
+
89
+
90
+ def _actionable_unchanged_segment(source: str, target: str) -> tuple[str, int] | None:
91
+ canonical_source = canonical_identity_text(source)
92
+ canonical_target = canonical_identity_text(target)
93
+ source_units = linguistic_units(canonical_source)
94
+ if (
95
+ canonical_source == canonical_target
96
+ and (
97
+ source_units >= MIN_SEGMENT_IDENTITY_UNITS
98
+ or COPYRIGHT_MARKER.match(canonical_source) is not None
99
+ )
100
+ ):
101
+ return canonical_source, source_units
102
+ return None
103
+
104
+
105
+ def identity_errors(
106
+ source: str | list[str],
107
+ target: str | list[str],
108
+ location: str = "$",
109
+ segment_locations: list[str] | None = None,
110
+ ) -> list[str]:
111
+ """Block unchanged complete inputs or aligned linguistic segments."""
112
+ if isinstance(source, list) and isinstance(target, list):
113
+ if len(source) != len(target):
114
+ # Structure and volume checks own count mismatches. Pairing shifted
115
+ # lists here could blame an unrelated segment for being unchanged.
116
+ return []
117
+ errors: list[str] = []
118
+ for index, (source_segment, target_segment) in enumerate(zip(source, target)):
119
+ segment_location = (
120
+ segment_locations[index]
121
+ if segment_locations is not None and index < len(segment_locations)
122
+ else f"{location}[{index}]"
123
+ )
124
+ unchanged = _actionable_unchanged_segment(source_segment, target_segment)
125
+ if unchanged is not None:
126
+ _, source_units = unchanged
127
+ errors.append(
128
+ f"{segment_location}: linguistic segment is unchanged from the source "
129
+ f"across {source_units} units; untranslated segment is blocked"
130
+ )
131
+ return errors
132
+
133
+ if not isinstance(source, str) or not isinstance(target, str):
134
+ return []
135
+ canonical_source = canonical_identity_text(source)
136
+ canonical_target = canonical_identity_text(target)
137
+ unchanged = _actionable_unchanged_segment(canonical_source, canonical_target)
138
+ if unchanged is not None:
139
+ _, source_units = unchanged
140
+ measured = (
141
+ f"{len(canonical_source)} characters"
142
+ if len(canonical_source) >= MIN_IDENTITY_SOURCE_CHARACTERS
143
+ else f"{source_units} linguistic units"
144
+ )
145
+ return [
146
+ f"{location}: target is unchanged from the source across "
147
+ f"{measured}; translation identity is blocked"
148
+ ]
149
+ return []
150
+
151
+
152
+ def unordered_identity_errors(
153
+ source_segments: list[str],
154
+ target_segments: list[str],
155
+ location: str = "$segments",
156
+ source_locations: list[str] | None = None,
157
+ ) -> list[str]:
158
+ """Find unchanged linguistic segments even when target order changes."""
159
+ target_counts = Counter(canonical_identity_text(segment) for segment in target_segments)
160
+ errors: list[str] = []
161
+ for index, source_segment in enumerate(source_segments):
162
+ canonical_source = canonical_identity_text(source_segment)
163
+ if not target_counts[canonical_source]:
164
+ continue
165
+ unchanged = _actionable_unchanged_segment(source_segment, canonical_source)
166
+ if unchanged is None:
167
+ continue
168
+ target_counts[canonical_source] -= 1
169
+ _, source_units = unchanged
170
+ segment_location = (
171
+ source_locations[index]
172
+ if source_locations is not None and index < len(source_locations)
173
+ else f"{location}[{index}]"
174
+ )
175
+ errors.append(
176
+ f"{segment_location}: linguistic segment is unchanged from the source "
177
+ f"across {source_units} units; untranslated segment is blocked"
178
+ )
179
+ return errors
180
+
181
+
182
+ def _cjk_dominant(text: str) -> bool:
183
+ letters = [character for character in text if unicodedata.category(character).startswith("L")]
184
+ if not letters:
185
+ return False
186
+ cjk = sum(
187
+ "\u3040" <= character <= "\u30ff"
188
+ or "\u3400" <= character <= "\u9fff"
189
+ or "\uac00" <= character <= "\ud7af"
190
+ for character in letters
191
+ )
192
+ return cjk / len(letters) >= 0.4
193
+
194
+
195
+ def volume_errors(source_segments: list[str], target_segments: list[str], location: str = "$") -> list[str]:
196
+ """Detect major omissions/additions without claiming semantic equivalence."""
197
+ errors: list[str] = []
198
+ source_nonempty = [segment for segment in source_segments if linguistic_units(segment)]
199
+ target_nonempty = [segment for segment in target_segments if linguistic_units(segment)]
200
+ source_total = sum(linguistic_units(segment) for segment in source_nonempty)
201
+ target_total = sum(linguistic_units(segment) for segment in target_nonempty)
202
+
203
+ if source_total >= MIN_TOTAL_SOURCE_UNITS:
204
+ minimum_ratio = 0.18 if _cjk_dominant(" ".join(target_nonempty)) else 0.45
205
+ ratio = target_total / source_total if source_total else 1.0
206
+ if ratio < minimum_ratio:
207
+ errors.append(
208
+ f"{location}: target linguistic volume is {target_total}/{source_total} "
209
+ f"units ({ratio:.1%}); minimum for this script is {minimum_ratio:.0%}"
210
+ )
211
+ if ratio > 3.0:
212
+ errors.append(
213
+ f"{location}: target linguistic volume is {target_total}/{source_total} "
214
+ f"units ({ratio:.1%}); possible unsupported addition"
215
+ )
216
+
217
+ if len(source_nonempty) != len(target_nonempty):
218
+ errors.append(
219
+ f"{location}: linguistic segment count changed from {len(source_nonempty)} to {len(target_nonempty)}"
220
+ )
221
+ return errors
222
+
223
+ for index, (source_segment, target_segment) in enumerate(zip(source_nonempty, target_nonempty)):
224
+ source_units = linguistic_units(source_segment)
225
+ target_units = linguistic_units(target_segment)
226
+ if source_units < MIN_SEGMENT_SOURCE_UNITS:
227
+ continue
228
+ minimum_ratio = 0.12 if _cjk_dominant(target_segment) else 0.25
229
+ ratio = target_units / source_units
230
+ if ratio < minimum_ratio:
231
+ errors.append(
232
+ f"{location}[{index}]: target segment volume is {target_units}/{source_units} "
233
+ f"units ({ratio:.1%}); possible truncation"
234
+ )
235
+ return errors
236
+
237
+
238
+ def json_segments(value: Any) -> list[str]:
239
+ if isinstance(value, dict):
240
+ # JSON object order is not semantic. Sort keys so source and target
241
+ # segments stay aligned even when a formatter reorders properties.
242
+ return [
243
+ segment
244
+ for key in sorted(value)
245
+ for segment in json_segments(value[key])
246
+ ]
247
+ if isinstance(value, list):
248
+ return [segment for child in value for segment in json_segments(child)]
249
+ return [value] if isinstance(value, str) else []
250
+
251
+
252
+ def json_located_segments(value: Any, path: str = "$") -> list[tuple[str, str]]:
253
+ """Return JSON string values with stable semantic paths."""
254
+ if isinstance(value, dict):
255
+ return [
256
+ segment
257
+ for key in sorted(value)
258
+ for segment in json_located_segments(value[key], f"{path}.{key}")
259
+ ]
260
+ if isinstance(value, list):
261
+ return [
262
+ segment
263
+ for index, child in enumerate(value)
264
+ for segment in json_located_segments(child, f"{path}[{index}]")
265
+ ]
266
+ return [(path, value)] if isinstance(value, str) else []
267
+
268
+
269
+ def jsonld_segments(value: Any, key: str | None = None) -> list[str]:
270
+ """Extract only Schema.org fields whose values are human-language copy."""
271
+ if isinstance(value, dict):
272
+ return [
273
+ segment
274
+ for child_key, child_value in value.items()
275
+ for segment in jsonld_segments(child_value, child_key)
276
+ ]
277
+ if isinstance(value, list):
278
+ return [segment for child in value for segment in jsonld_segments(child, key)]
279
+ if isinstance(value, str) and key in JSONLD_LINGUISTIC_KEYS:
280
+ return [value]
281
+ return []
282
+
283
+
284
+ def xml_segments(text: str) -> list[str]:
285
+ try:
286
+ root = ET.fromstring(text)
287
+ except ET.ParseError:
288
+ return []
289
+ return [segment for segment in root.itertext() if segment.strip()]
290
+
291
+
292
+ class LinguisticHTMLParser(HTMLParser):
293
+ def __init__(self) -> None:
294
+ super().__init__(convert_charrefs=True)
295
+ self.segments: list[str] = []
296
+ self.locations: list[str] = []
297
+ self.stack: list[dict[str, Any]] = []
298
+ self.root_children: dict[str, int] = {}
299
+ self.root_text_count = 0
300
+
301
+ def _append_segment(self, value: str, location: str) -> None:
302
+ self.segments.append(value)
303
+ self.locations.append(location)
304
+
305
+ def _next_element_path(self, tag: str) -> str:
306
+ counts = self.stack[-1]["children"] if self.stack else self.root_children
307
+ index = counts.get(tag, 0)
308
+ counts[tag] = index + 1
309
+ parent = self.stack[-1]["path"] if self.stack else "$html"
310
+ return f"{parent}/{tag}[{index}]"
311
+
312
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
313
+ normalized_tag = tag.casefold()
314
+ attribute_map = {name.casefold(): value or "" for name, value in attrs}
315
+ element_path = self._next_element_path(normalized_tag)
316
+ self.stack.append({
317
+ "tag": normalized_tag,
318
+ "type": attribute_map.get("type", "").casefold(),
319
+ "path": element_path,
320
+ "children": {},
321
+ "text_count": 0,
322
+ })
323
+ meta_name = attribute_map.get("name", "").casefold()
324
+ meta_property = attribute_map.get("property", "").casefold()
325
+ for name, value in attrs:
326
+ normalized = name.casefold()
327
+ linguistic_meta = normalized == "content" and (
328
+ meta_name in TRANSLATABLE_META_NAMES or meta_property in TRANSLATABLE_META_PROPERTIES
329
+ )
330
+ if value and (normalized in TRANSLATABLE_HTML_ATTRIBUTES or linguistic_meta):
331
+ self._append_segment(value, f"{element_path}/@{normalized}")
332
+
333
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
334
+ self.handle_starttag(tag, attrs)
335
+ self.handle_endtag(tag)
336
+
337
+ def handle_endtag(self, tag: str) -> None:
338
+ for index in range(len(self.stack) - 1, -1, -1):
339
+ if self.stack[index]["tag"] == tag.casefold():
340
+ del self.stack[index:]
341
+ break
342
+
343
+ def handle_data(self, data: str) -> None:
344
+ current = self.stack[-1] if self.stack else None
345
+ current_tag = current["tag"] if current else ""
346
+ current_type = current["type"] if current else ""
347
+ if current_tag in HTML_CODE_ELEMENTS:
348
+ if current_tag == "script" and current_type == "application/ld+json":
349
+ try:
350
+ for index, segment in enumerate(jsonld_segments(json.loads(data))):
351
+ self._append_segment(segment, f"{current['path']}/jsonld[{index}]")
352
+ except json.JSONDecodeError:
353
+ pass
354
+ return
355
+ if data.strip():
356
+ if current:
357
+ text_index = current["text_count"]
358
+ current["text_count"] = text_index + 1
359
+ location = f"{current['path']}/text()[{text_index}]"
360
+ else:
361
+ text_index = self.root_text_count
362
+ self.root_text_count += 1
363
+ location = f"$html/text()[{text_index}]"
364
+ self._append_segment(data, location)
365
+
366
+
367
+ def html_segments(text: str) -> list[str]:
368
+ return [segment for _, segment in html_located_segments(text)]
369
+
370
+
371
+ def html_located_segments(text: str) -> list[tuple[str, str]]:
372
+ parser = LinguisticHTMLParser()
373
+ try:
374
+ parser.feed(text)
375
+ parser.close()
376
+ except Exception:
377
+ return []
378
+ return list(zip(parser.locations, parser.segments))
379
+
380
+
381
+ def icu_selectors(text: str, start: int) -> list[tuple[str, tuple[int, int]]]:
382
+ """Read top-level selector names from one ICU plural/select expression."""
383
+ selectors: list[tuple[str, tuple[int, int]]] = []
384
+ depth = 0
385
+ position = start
386
+ while position < len(text):
387
+ character = text[position]
388
+ if character == "}" and depth == 0:
389
+ break
390
+ if depth == 0:
391
+ match = ICU_SELECTOR_AT_POSITION.match(text, position)
392
+ if match:
393
+ selectors.append((match.group(1), match.span(1)))
394
+ position = match.end()
395
+ continue
396
+ if character == "{":
397
+ depth += 1
398
+ elif character == "}" and depth:
399
+ depth -= 1
400
+ position += 1
401
+ return selectors
402
+
403
+
404
+ def protected_tokens(text: str) -> Counter[tuple[str, str]]:
405
+ """Return protected token counts, avoiding duplicate nested matches."""
406
+ tokens: Counter[tuple[str, str]] = Counter()
407
+ occupied: list[tuple[int, int]] = []
408
+
409
+ def overlaps(start: int, end: int) -> bool:
410
+ return any(start < other_end and end > other_start for other_start, other_end in occupied)
411
+
412
+ for match in FENCED_CODE.finditer(text):
413
+ tokens[("fenced code", match.group(0))] += 1
414
+ occupied.append(match.span())
415
+
416
+ icu_headers = list(ICU_HEADER.finditer(text))
417
+ for match in icu_headers:
418
+ tokens[("ICU argument", match.group(1))] += 1
419
+ tokens[("ICU formatter", match.group(2).lower())] += 1
420
+ occupied.append(match.span())
421
+ for selector, span in icu_selectors(text, match.end()):
422
+ tokens[("ICU selector", selector)] += 1
423
+ occupied.append(span)
424
+ if icu_headers:
425
+ tokens[("ICU number sign", "#")] += text.count("#")
426
+
427
+ for label, pattern in TOKEN_PATTERNS:
428
+ for match in pattern.finditer(text):
429
+ if overlaps(*match.span()):
430
+ continue
431
+ value = match.group(1) if label == "ICU argument" else match.group(0)
432
+ if label == "URL":
433
+ # Sentence punctuation after a bare URL is not part of the URL.
434
+ value = value.rstrip(".,;:!?")
435
+ tokens[(label, value)] += 1
436
+ occupied.append(match.span())
437
+ return tokens
438
+
439
+
440
+ def compare_tokens(source: str, target: str, location: str) -> list[str]:
441
+ source_tokens = protected_tokens(source)
442
+ target_tokens = protected_tokens(target)
443
+ errors: list[str] = []
444
+ for token, count in (source_tokens - target_tokens).items():
445
+ errors.append(f"{location}: missing {count}× {token[0]} {token[1]!r}")
446
+ for token, count in (target_tokens - source_tokens).items():
447
+ errors.append(f"{location}: added {count}× {token[0]} {token[1]!r}")
448
+ return errors
449
+
450
+
451
+ def scalar_type(value: Any) -> type[Any]:
452
+ # bool is a subclass of int; exact types matter in resource files.
453
+ return type(value)
454
+
455
+
456
+ def compare_json(source: Any, target: Any, path: str = "$") -> list[str]:
457
+ errors: list[str] = []
458
+ if scalar_type(source) is not scalar_type(target):
459
+ return [f"{path}: type changed from {type(source).__name__} to {type(target).__name__}"]
460
+
461
+ if isinstance(source, dict):
462
+ source_keys = set(source)
463
+ target_keys = set(target)
464
+ for key in sorted(source_keys - target_keys):
465
+ errors.append(f"{path}: missing key {key!r}")
466
+ for key in sorted(target_keys - source_keys):
467
+ errors.append(f"{path}: added key {key!r}")
468
+ for key in source.keys() & target.keys():
469
+ errors.extend(compare_json(source[key], target[key], f"{path}.{key}"))
470
+ return errors
471
+
472
+ if isinstance(source, list):
473
+ if len(source) != len(target):
474
+ errors.append(f"{path}: array length changed from {len(source)} to {len(target)}")
475
+ for index, (source_item, target_item) in enumerate(zip(source, target)):
476
+ errors.extend(compare_json(source_item, target_item, f"{path}[{index}]"))
477
+ return errors
478
+
479
+ if isinstance(source, str):
480
+ return compare_tokens(source, target, path)
481
+
482
+ if source != target:
483
+ errors.append(f"{path}: non-string value changed from {source!r} to {target!r}")
484
+ return errors
485
+
486
+
487
+ def token_signature(text: str) -> tuple[tuple[tuple[str, str], int], ...]:
488
+ return tuple(sorted(protected_tokens(text).items()))
489
+
490
+
491
+ class TranslationHTMLParser(HTMLParser):
492
+ """Reduce HTML to the parts a translation must preserve."""
493
+
494
+ def __init__(self) -> None:
495
+ super().__init__(convert_charrefs=False)
496
+ self.events: list[tuple[Any, ...]] = []
497
+ self.open_elements: list[tuple[str, dict[str, str | None]]] = []
498
+
499
+ @staticmethod
500
+ def attribute_signature(
501
+ tag: str, attrs: list[tuple[str, str | None]]
502
+ ) -> tuple[Any, ...]:
503
+ signature: list[tuple[Any, ...]] = []
504
+ attribute_map = {name.casefold(): value for name, value in attrs}
505
+ meta_name = (attribute_map.get("name") or "").casefold()
506
+ meta_property = (attribute_map.get("property") or "").casefold()
507
+ content_is_linguistic = tag.casefold() == "meta" and (
508
+ meta_name in TRANSLATABLE_META_NAMES
509
+ or meta_property in TRANSLATABLE_META_PROPERTIES
510
+ )
511
+ for name, value in attrs:
512
+ normalized_name = name.casefold()
513
+ if normalized_name in TRANSLATABLE_HTML_ATTRIBUTES or (
514
+ normalized_name == "content" and content_is_linguistic
515
+ ):
516
+ signature.append((name, "translatable", token_signature(value or "")))
517
+ else:
518
+ signature.append((name, "fixed", value))
519
+ return tuple(signature)
520
+
521
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
522
+ self.events.append(("start", tag, self.attribute_signature(tag, attrs)))
523
+ self.open_elements.append((tag, dict(attrs)))
524
+
525
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
526
+ self.events.append(("empty", tag, self.attribute_signature(tag, attrs)))
527
+
528
+ def handle_endtag(self, tag: str) -> None:
529
+ self.events.append(("end", tag))
530
+ for index in range(len(self.open_elements) - 1, -1, -1):
531
+ if self.open_elements[index][0] == tag:
532
+ del self.open_elements[index:]
533
+ break
534
+
535
+ def handle_data(self, data: str) -> None:
536
+ current = self.open_elements[-1] if self.open_elements else None
537
+ current_element = current[0] if current else None
538
+ if current_element == "script" and (current[1].get("type") or "").casefold() == "application/ld+json":
539
+ try:
540
+ self.events.append(("json-ld", jsonld_signature(json.loads(data))))
541
+ except json.JSONDecodeError:
542
+ self.events.append(("invalid json-ld", data))
543
+ return
544
+ if current_element in HTML_CODE_ELEMENTS:
545
+ self.events.append(("code", current_element, data))
546
+ return
547
+ signature = token_signature(data)
548
+ if signature:
549
+ self.events.append(("text tokens", signature))
550
+
551
+ def handle_comment(self, data: str) -> None:
552
+ self.events.append(("comment", data))
553
+
554
+ def handle_decl(self, decl: str) -> None:
555
+ self.events.append(("declaration", decl))
556
+
557
+ def handle_entityref(self, name: str) -> None:
558
+ self.events.append(("entity", name))
559
+
560
+ def handle_charref(self, name: str) -> None:
561
+ self.events.append(("character reference", name))
562
+
563
+ def handle_pi(self, data: str) -> None:
564
+ self.events.append(("processing instruction", data))
565
+
566
+
567
+ def jsonld_signature(value: Any, key: str | None = None) -> Any:
568
+ if isinstance(value, dict):
569
+ return ("object", tuple((item_key, jsonld_signature(item_value, item_key)) for item_key, item_value in value.items()))
570
+ if isinstance(value, list):
571
+ return ("array", tuple(jsonld_signature(item, key) for item in value))
572
+ if isinstance(value, str) and key in JSONLD_LINGUISTIC_KEYS:
573
+ return ("translatable", token_signature(value))
574
+ return (type(value).__name__, value)
575
+
576
+
577
+ def parse_html(text: str, location: str) -> tuple[list[tuple[Any, ...]], list[str]]:
578
+ parser = TranslationHTMLParser()
579
+ try:
580
+ parser.feed(text)
581
+ parser.close()
582
+ except Exception as exc: # HTMLParser can surface malformed declarations.
583
+ return [], [f"{location}: invalid HTML ({exc})"]
584
+ return parser.events, []
585
+
586
+
587
+ def compare_html(source: str, target: str) -> list[str]:
588
+ source_events, source_errors = parse_html(source, "source")
589
+ target_events, target_errors = parse_html(target, "target")
590
+ errors = source_errors + target_errors
591
+ if errors:
592
+ return errors
593
+
594
+ if len(source_events) != len(target_events):
595
+ errors.append(
596
+ f"$: HTML event count changed from {len(source_events)} to {len(target_events)}"
597
+ )
598
+ for index, (source_event, target_event) in enumerate(zip(source_events, target_events)):
599
+ if source_event != target_event:
600
+ errors.append(
601
+ f"$: HTML event {index} changed from {source_event!r} to {target_event!r}"
602
+ )
603
+ if len(errors) >= 20:
604
+ errors.append("$: additional HTML differences omitted")
605
+ break
606
+ return errors
607
+
608
+
609
+ def compare_xml(source: str, target: str) -> list[str]:
610
+ """Compare XML/Android/XLIFF structure while allowing linguistic text."""
611
+ try:
612
+ source_root, target_root = ET.fromstring(source), ET.fromstring(target)
613
+ except ET.ParseError as error:
614
+ return [f"$: invalid XML ({error})"]
615
+ errors: list[str] = []
616
+
617
+ def walk(left: ET.Element, right: ET.Element, path: str) -> None:
618
+ if left.tag != right.tag:
619
+ errors.append(f"{path}: tag changed from {left.tag!r} to {right.tag!r}")
620
+ return
621
+ if left.attrib != right.attrib:
622
+ errors.append(f"{path}: attributes changed from {left.attrib!r} to {right.attrib!r}")
623
+ errors.extend(compare_tokens(left.text or "", right.text or "", path + ".text"))
624
+ errors.extend(compare_tokens(left.tail or "", right.tail or "", path + ".tail"))
625
+ if len(left) != len(right):
626
+ errors.append(f"{path}: child count changed from {len(left)} to {len(right)}")
627
+ for index, (left_child, right_child) in enumerate(zip(left, right)):
628
+ walk(left_child, right_child, f"{path}/{index}")
629
+ walk(source_root, target_root, "$/$root")
630
+ return errors
631
+
632
+
633
+ PO_ENTRY = re.compile(r'^(msgctxt|msgid|msgid_plural|msgstr(?:\[\d+\])?)\s+"(.*)"$', re.MULTILINE)
634
+ APPLE_STRING = re.compile(r'^\s*"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)"\s*;\s*$', re.MULTILINE)
635
+ TIMESTAMP = re.compile(r"^\s*(?:\d{2}:)?\d{2}:\d{2}[,.]\d{3}\s+-->\s+(?:\d{2}:)?\d{2}:\d{2}[,.]\d{3}.*$", re.MULTILINE)
636
+
637
+
638
+ def compare_po(source: str, target: str) -> list[str]:
639
+ left, right = PO_ENTRY.findall(source), PO_ENTRY.findall(target)
640
+ left_keys = [(kind, value) for kind, value in left if kind in {"msgctxt", "msgid", "msgid_plural"}]
641
+ right_keys = [(kind, value) for kind, value in right if kind in {"msgctxt", "msgid", "msgid_plural"}]
642
+ errors = [] if left_keys == right_keys else ["$: PO contexts and msgids changed"]
643
+ left_values = [value for kind, value in left if kind.startswith("msgstr")]
644
+ right_values = [value for kind, value in right if kind.startswith("msgstr")]
645
+ if len(left_values) != len(right_values):
646
+ errors.append("$: PO msgstr count changed")
647
+ for index, (a, b) in enumerate(zip(left_values, right_values)):
648
+ errors.extend(compare_tokens(a, b, f"$.msgstr[{index}]"))
649
+ return errors
650
+
651
+
652
+ def compare_apple_strings(source: str, target: str) -> list[str]:
653
+ left, right = APPLE_STRING.findall(source), APPLE_STRING.findall(target)
654
+ if [key for key, _ in left] != [key for key, _ in right]:
655
+ return ["$: Apple .strings keys or ordering changed"]
656
+ errors: list[str] = []
657
+ for (key, source_value), (_, target_value) in zip(left, right):
658
+ errors.extend(compare_tokens(source_value, target_value, f"$.{key}"))
659
+ return errors
660
+
661
+
662
+ def compare_subtitles(source: str, target: str) -> list[str]:
663
+ left, right = TIMESTAMP.findall(source), TIMESTAMP.findall(target)
664
+ return [] if left == right else ["$: subtitle timestamps or cue settings changed"]
665
+
666
+
667
+ def po_segments(text: str) -> list[str]:
668
+ entries = PO_ENTRY.findall(text)
669
+ translated = [value for kind, value in entries if kind.startswith("msgstr") and value]
670
+ return translated or [value for kind, value in entries if kind in {"msgid", "msgid_plural"} and value]
671
+
672
+
673
+ def apple_segments(text: str) -> list[str]:
674
+ return [value for _, value in APPLE_STRING.findall(text)]
675
+
676
+
677
+ def subtitle_segments(text: str) -> list[str]:
678
+ segments: list[str] = []
679
+ for line in text.splitlines():
680
+ stripped = line.strip()
681
+ if not stripped or stripped.isdigit() or TIMESTAMP.fullmatch(line):
682
+ continue
683
+ if stripped.startswith("[") and stripped.endswith("]"):
684
+ continue
685
+ if stripped.startswith("Dialogue:"):
686
+ # ASS dialogue has nine fixed comma-separated fields before Text.
687
+ fields = line.split(",", 9)
688
+ if len(fields) == 10 and fields[-1].strip():
689
+ segments.append(fields[-1])
690
+ continue
691
+ if stripped.startswith(("WEBVTT", "NOTE", "STYLE", "REGION", "Format:")):
692
+ continue
693
+ segments.append(line)
694
+ return segments
695
+
696
+
697
+ def detect_content_format(text: str) -> str:
698
+ """Infer a structured format from measurable syntax, without caller trust."""
699
+ stripped = text.lstrip("\ufeff\n\r\t ")
700
+ if not stripped:
701
+ return "text"
702
+ if stripped[:1] in {"{", "["}:
703
+ try:
704
+ json.loads(stripped)
705
+ return "json"
706
+ except json.JSONDecodeError:
707
+ pass
708
+ if re.search(
709
+ r"<(?:!doctype\s+html|html|head|body|main|section|article|nav|header|footer|div|p|h[1-6])\b",
710
+ stripped,
711
+ re.IGNORECASE,
712
+ ):
713
+ return "html"
714
+ if stripped.startswith("<"):
715
+ try:
716
+ ET.fromstring(stripped)
717
+ return "xml"
718
+ except ET.ParseError:
719
+ pass
720
+ if re.search(r"^msg(?:id|str|ctxt)\b", text, re.MULTILINE):
721
+ return "po"
722
+ if APPLE_STRING.search(text):
723
+ return "strings"
724
+ if TIMESTAMP.search(text) or re.search(r"^Dialogue:", text, re.MULTILINE):
725
+ return "subtitle"
726
+ return "text"
727
+
728
+
729
+ def linguistic_segments(text: str, selected_format: str) -> list[str]:
730
+ """Extract human-language segments for volume checks in one known format."""
731
+ if selected_format == "json":
732
+ try:
733
+ return json_segments(json.loads(text.lstrip("\ufeff")))
734
+ except json.JSONDecodeError:
735
+ return [text]
736
+ if selected_format == "html":
737
+ return html_segments(text)
738
+ if selected_format == "xml":
739
+ return xml_segments(text)
740
+ if selected_format == "po":
741
+ return po_segments(text)
742
+ if selected_format == "strings":
743
+ return apple_segments(text)
744
+ if selected_format == "subtitle":
745
+ return subtitle_segments(text)
746
+ return [text]
747
+
748
+
749
+ def translation_volume_errors(source: str, target: str) -> list[str]:
750
+ """Run the unconditional, auto-detected volume gate used by MCP release."""
751
+ selected_format = detect_content_format(source)
752
+ return volume_errors(
753
+ linguistic_segments(source, selected_format),
754
+ linguistic_segments(target, selected_format),
755
+ "$",
756
+ )
757
+
758
+
759
+ def structured_identity_errors(
760
+ source: str,
761
+ target: str,
762
+ selected_format: str,
763
+ ) -> list[str]:
764
+ """Compare aligned user-visible segments after whole-input identity passes."""
765
+ if selected_format == "text":
766
+ return []
767
+ if selected_format == "json":
768
+ try:
769
+ source_data = json.loads(source.lstrip("\ufeff"))
770
+ target_data = json.loads(target.lstrip("\ufeff"))
771
+ except json.JSONDecodeError:
772
+ return []
773
+ source_located = json_located_segments(source_data)
774
+ target_by_path = dict(json_located_segments(target_data))
775
+ common = [
776
+ (path, value, target_by_path[path])
777
+ for path, value in source_located
778
+ if path in target_by_path
779
+ ]
780
+ return identity_errors(
781
+ [source_value for _, source_value, _ in common],
782
+ [target_value for _, _, target_value in common],
783
+ "$segments",
784
+ [path for path, _, _ in common],
785
+ )
786
+ if selected_format == "html":
787
+ source_located = html_located_segments(source)
788
+ return unordered_identity_errors(
789
+ [segment for _, segment in source_located],
790
+ html_segments(target),
791
+ "$segments",
792
+ [path for path, _ in source_located],
793
+ )
794
+ return unordered_identity_errors(
795
+ linguistic_segments(source, selected_format),
796
+ linguistic_segments(target, selected_format),
797
+ "$segments",
798
+ )
799
+
800
+
801
+ def translation_identity_errors(source: str, target: str) -> list[str]:
802
+ """Run the mandatory whole-input and auto-detected segment identity gate."""
803
+ whole_input_errors = identity_errors(source, target, "$")
804
+ if whole_input_errors:
805
+ return whole_input_errors
806
+ selected_format = detect_content_format(source)
807
+ return structured_identity_errors(source, target, selected_format)
808
+
809
+
810
+ def read_utf8(path: Path) -> tuple[str | None, list[str]]:
811
+ try:
812
+ return path.read_text(encoding="utf-8"), []
813
+ except UnicodeDecodeError as exc:
814
+ return None, [f"{path}: not valid UTF-8 ({exc})"]
815
+ except OSError as exc:
816
+ return None, [f"{path}: cannot read file ({exc})"]
817
+
818
+
819
+ def normalization_errors(text: str, path: Path) -> list[str]:
820
+ if unicodedata.is_normalized("NFC", text):
821
+ return []
822
+ return [f"{path}: target text is not Unicode NFC-normalized"]
823
+
824
+
825
+ def parse_json(text: str, path: Path) -> tuple[Any | None, list[str]]:
826
+ try:
827
+ return json.loads(text), []
828
+ except json.JSONDecodeError as exc:
829
+ return None, [f"{path}: invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}"]
830
+
831
+
832
+ def print_errors(errors: Iterable[str]) -> None:
833
+ for error in errors:
834
+ print(f"ERROR: {error}", file=sys.stderr)
835
+
836
+
837
+ def main() -> int:
838
+ parser = argparse.ArgumentParser(
839
+ description="Verify source-target non-identity, Unicode normalization, structure, volume, and protected tokens in a translation."
840
+ )
841
+ parser.add_argument("source", type=Path)
842
+ parser.add_argument("target", type=Path)
843
+ parser.add_argument("--format", choices=("auto", "text", "json", "html", "xml", "po", "strings", "subtitle"), default="auto")
844
+ args = parser.parse_args()
845
+
846
+ source_text, source_errors = read_utf8(args.source)
847
+ target_text, target_errors = read_utf8(args.target)
848
+ errors = source_errors + target_errors
849
+ if errors:
850
+ print_errors(errors)
851
+ return 2
852
+ assert source_text is not None and target_text is not None
853
+
854
+ errors.extend(normalization_errors(target_text, args.target))
855
+ whole_identity_errors = identity_errors(source_text, target_text, "$")
856
+ errors.extend(whole_identity_errors)
857
+ selected_format = args.format
858
+ if selected_format == "auto":
859
+ suffix = args.source.suffix.lower()
860
+ if suffix == ".json":
861
+ selected_format = "json"
862
+ elif suffix in {".html", ".htm"}:
863
+ selected_format = "html"
864
+ elif suffix in {".xml", ".xliff", ".xlf"}:
865
+ selected_format = "xml"
866
+ elif suffix in {".po", ".pot"}:
867
+ selected_format = "po"
868
+ elif suffix == ".strings":
869
+ selected_format = "strings"
870
+ elif suffix in {".srt", ".vtt", ".ass"}:
871
+ selected_format = "subtitle"
872
+ else:
873
+ selected_format = "text"
874
+
875
+ if not whole_identity_errors:
876
+ errors.extend(structured_identity_errors(source_text, target_text, selected_format))
877
+
878
+ if selected_format == "json":
879
+ source_data, parse_source_errors = parse_json(source_text, args.source)
880
+ target_data, parse_target_errors = parse_json(target_text, args.target)
881
+ errors.extend(parse_source_errors)
882
+ errors.extend(parse_target_errors)
883
+ if not parse_source_errors and not parse_target_errors:
884
+ errors.extend(compare_json(source_data, target_data))
885
+ errors.extend(volume_errors(json_segments(source_data), json_segments(target_data), "$"))
886
+ elif selected_format == "html":
887
+ errors.extend(compare_html(source_text, target_text))
888
+ errors.extend(volume_errors(html_segments(source_text), html_segments(target_text), "$"))
889
+ elif selected_format == "xml":
890
+ errors.extend(compare_xml(source_text, target_text))
891
+ errors.extend(volume_errors(xml_segments(source_text), xml_segments(target_text), "$"))
892
+ elif selected_format == "po":
893
+ errors.extend(compare_po(source_text, target_text))
894
+ errors.extend(volume_errors(po_segments(source_text), po_segments(target_text), "$"))
895
+ elif selected_format == "strings":
896
+ errors.extend(compare_apple_strings(source_text, target_text))
897
+ errors.extend(volume_errors(apple_segments(source_text), apple_segments(target_text), "$"))
898
+ elif selected_format == "subtitle":
899
+ errors.extend(compare_subtitles(source_text, target_text))
900
+ errors.extend(volume_errors(subtitle_segments(source_text), subtitle_segments(target_text), "$"))
901
+ else:
902
+ errors.extend(compare_tokens(source_text, target_text, "$"))
903
+ errors.extend(volume_errors([source_text], [target_text], "$"))
904
+
905
+ if errors:
906
+ print_errors(errors)
907
+ return 1
908
+ print(
909
+ "OK: source-target and structured-segment identity thresholds, measurable structure, protected tokens, linguistic volume, and Unicode NFC are intact. "
910
+ "This does not prove semantic fidelity, completeness, or native quality."
911
+ )
912
+ return 0
913
+
914
+
915
+ if __name__ == "__main__":
916
+ raise SystemExit(main())