blun-king-cli 9.1.6 → 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 (28) 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/blun.mjs +4 -2
  5. package/package.json +50 -47
  6. package/standard-skills/blun-app-design-system/SKILL.md +30 -0
  7. package/standard-skills/blun-app-design-system/provenance.json +17 -0
  8. package/standard-skills/blun-app-design-system/references/tokens.md +48 -0
  9. package/standard-skills/blun-app-design-system/scripts/check-blun-design.mjs +49 -0
  10. package/standard-tools/language-guard/blun_language_guard.py +686 -0
  11. package/standard-tools/language-guard/check_diacritics.py +353 -0
  12. package/standard-tools/language-guard/guard_service_client.py +82 -0
  13. package/standard-tools/language-guard/language_quality.py +172 -0
  14. package/standard-tools/language-guard/translation_guard.py +916 -0
  15. package/standard-tools/manifest.json +76 -0
  16. package/skills/design-taste-frontend/SKILL.md +0 -1206
  17. package/skills/full-output-enforcement/SKILL.md +0 -49
  18. package/skills/high-end-visual-design/SKILL.md +0 -98
  19. package/skills/image-to-code/SKILL.md +0 -1228
  20. package/skills/industrial-brutalist-ui/SKILL.md +0 -92
  21. package/skills/minimalist-ui/SKILL.md +0 -85
  22. package/skills/motion-design-taste/SKILL.md +0 -74
  23. package/skills/premortem/SKILL.md +0 -148
  24. package/skills/redesign-existing-projects/SKILL.md +0 -178
  25. package/skills/screenshot-lesen/SKILL.md +0 -52
  26. package/skills/stitch-design-taste/DESIGN.md +0 -121
  27. package/skills/stitch-design-taste/SKILL.md +0 -184
  28. package/skills/web-lesen/SKILL.md +0 -58
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env python3
2
+ """Flag common ASCII substitutions and non-NFC text in prose files."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import re
8
+ import sys
9
+ import unicodedata
10
+ from pathlib import Path
11
+
12
+
13
+ RULES: dict[str, tuple[tuple[str, str], ...]] = {
14
+ "de": (
15
+ (r"\bfuer\b", "für"),
16
+ (r"\bueber", "über…"),
17
+ (r"\bzurueck", "zurück…"),
18
+ (r"\bmuess", "müss…"),
19
+ (r"\bkoenn", "könn…"),
20
+ (r"\bmoech", "möch…"),
21
+ (r"\bschoen", "schön…"),
22
+ (r"\bgrues", "grüß…"),
23
+ (r"\bkoeln\b", "Köln"),
24
+ (r"\bgroess", "größ…"),
25
+ (r"\baender", "änder…"),
26
+ (r"\bpruef", "prüf…"),
27
+ (r"\bgepruef", "geprüf…"),
28
+ (r"\buebersetz", "übersetz…"),
29
+ (r"\boeffn", "öffn…"),
30
+ (r"\bloesch", "lösch…"),
31
+ (r"\bwaehr", "währ…"),
32
+ (r"\bwaer(?:e|en|st|t)\b", "wär…"),
33
+ (r"\bhaett", "hätt…"),
34
+ (r"\bwuerd", "würd…"),
35
+ (r"\bduerf", "dürf…"),
36
+ (r"\berklaer", "erklär…"),
37
+ (r"\bgeklaer", "geklär…"),
38
+ (r"\bnaechst", "nächst…"),
39
+ (r"\bspaet", "spät…"),
40
+ (r"\bfrueh", "früh…"),
41
+ (r"\bwuensch", "wünsch…"),
42
+ (r"\bgueltig", "gültig…"),
43
+ (r"\bzuverlaess", "zuverläss…"),
44
+ (r"\bhaeufig", "häufig…"),
45
+ (r"\bzusaetz", "zusätz…"),
46
+ (r"\bvollstaendig", "vollständig…"),
47
+ (r"\bpersoenlich", "persönlich…"),
48
+ (r"\bnatuerlich", "natürlich…"),
49
+ (r"\bmoeglich", "möglich…"),
50
+ (r"\bnoetig", "nötig…"),
51
+ (r"\baehnlich", "ähnlich…"),
52
+ (r"\bgehoer", "gehör…"),
53
+ (r"\benthaelt", "enthält"),
54
+ (r"\bwaehl", "wähl…"),
55
+ (r"\bveroeffent", "veröffent…"),
56
+ (r"\bunterstuetz", "unterstütz…"),
57
+ (r"\bbestaet", "bestät…"),
58
+ (r"\bergaenz", "ergänz…"),
59
+ ),
60
+ "sv": (
61
+ (r"\bGoteborg\b", "Göteborg"),
62
+ (r"\bMalmo\b", "Malmö"),
63
+ (r"\bOresund\b", "Öresund"),
64
+ (r"\bsmorgasbord\b", "smörgåsbord"),
65
+ (r"\bAngstrom\b", "Ångström"),
66
+ (r"\bforstar\b", "förstår"),
67
+ (r"\bbehover\b", "behöver"),
68
+ (r"\bmojlig", "möjlig…"),
69
+ (r"\bborja", "börja…"),
70
+ (r"\bfraga", "fråga…"),
71
+ (r"\bsjalv", "själv…"),
72
+ (r"\bhar ar\b", "här är"),
73
+ (r"\bdet ar\b", "det är"),
74
+ (r"\bjag ar\b", "jag är"),
75
+ (r"\bvi ar\b", "vi är"),
76
+ (r"\bdu ar\b", "du är"),
77
+ (r"\bar det\b", "är det"),
78
+ (r"\bfor att\b", "för att"),
79
+ (r"\bfor dig\b", "för dig"),
80
+ (r"\bfor er\b", "för er"),
81
+ (r"\bpa en\b", "på en"),
82
+ (r"\bpa ett\b", "på ett"),
83
+ (r"\bpa den\b", "på den"),
84
+ (r"\bpa det\b", "på det"),
85
+ (r"\bsprak", "språk…"),
86
+ (r"\boppn", "öppn…"),
87
+ ),
88
+ "es": (
89
+ (r"\bespanol\b", "español"),
90
+ (r"\bsenor", "señor…"),
91
+ (r"\binformacion\b", "información"),
92
+ (r"\btambien\b", "también"),
93
+ (r"\bpagina\b", "página"),
94
+ (r"\bnumero\b", "número"),
95
+ (r"\badios\b", "adiós"),
96
+ (r"\bQue\?", "¿Qué?"),
97
+ ),
98
+ "fr": (
99
+ (r"\bfrancais\b", "français"),
100
+ (r"\becole\b", "école"),
101
+ (r"\bcreme brulee\b", "crème brûlée"),
102
+ (r"\btres\b", "très"),
103
+ (r"\bdeja\b", "déjà"),
104
+ (r"\betre\b", "être"),
105
+ ),
106
+ "pt": (
107
+ (r"\bSao Paulo\b", "São Paulo"),
108
+ (r"\bcoracao\b", "coração"),
109
+ (r"\bportugues\b", "português"),
110
+ (r"\bvoce\b", "você"),
111
+ (r"\bnao\b", "não"),
112
+ ),
113
+ "it": (
114
+ (r"\bperche\b", "perché"),
115
+ (r"\bcitta\b", "città"),
116
+ (r"\bpiu\b", "più"),
117
+ ),
118
+ "da": (
119
+ (r"\bKobenhavn\b", "København"),
120
+ (r"\bvaer", "vær…"),
121
+ (r"\bsprogforstaelse\b", "sprogforståelse"),
122
+ ),
123
+ "no": (
124
+ (r"\bvaer", "vær…"),
125
+ (r"\bforsta", "forstå…"),
126
+ (r"\bsporsmal\b", "spørsmål"),
127
+ ),
128
+ "is": (
129
+ (r"\bReykjavik\b", "Reykjavík"),
130
+ (r"\bThingvellir\b", "Þingvellir"),
131
+ (r"\bislenska\b", "íslenska"),
132
+ ),
133
+ "cs": (
134
+ (r"\bcestina\b", "čeština"),
135
+ (r"\bcesk", "česk…"),
136
+ (r"\bprilis\b", "příliš"),
137
+ (r"\bDvorak\b", "Dvořák"),
138
+ (r"\bdekuji\b", "děkuji"),
139
+ (r"\bmesto\b", "město"),
140
+ ),
141
+ "sk": (
142
+ (r"\bslovencina\b", "slovenčina"),
143
+ (r"\bdakujem\b", "ďakujem"),
144
+ ),
145
+ "pl": (
146
+ (r"\bLodz\b", "Łódź"),
147
+ (r"\bjezyk\b", "język"),
148
+ (r"\bdziekuje\b", "dziękuję"),
149
+ (r"\bzolty\b", "żółty"),
150
+ ),
151
+ "hu": (
152
+ (r"\bMagyarorszag\b", "Magyarország"),
153
+ (r"\bkoszonom\b", "köszönöm"),
154
+ (r"\borom\b", "öröm"),
155
+ ),
156
+ "ro": (
157
+ (r"\bromana\b", "română"),
158
+ (r"\bBucuresti\b", "București"),
159
+ (r"\bmultumesc\b", "mulțumesc"),
160
+ (r"\btara\b", "țară"),
161
+ ),
162
+ "tr": (
163
+ (r"\bIstanbul\b", "İstanbul"),
164
+ (r"\bTurkce\b", "Türkçe"),
165
+ (r"\btesekkur", "teşekkür…"),
166
+ (r"\bgorusuruz\b", "görüşürüz"),
167
+ ),
168
+ "hr": (
169
+ (r"\bDorde\b", "Đorđe"),
170
+ (r"\bvec\b", "već"),
171
+ (r"\bcovjek\b", "čovjek"),
172
+ ),
173
+ "bs": (
174
+ (r"\bDorde\b", "Đorđe"),
175
+ (r"\bvec\b", "već"),
176
+ (r"\bcovjek\b", "čovjek"),
177
+ ),
178
+ "sr": (
179
+ (r"\bDorde\b", "Đorđe"),
180
+ (r"\bvec\b", "već"),
181
+ (r"\bcovek\b", "čovek"),
182
+ ),
183
+ "sl": (
184
+ (r"\bslovenscina\b", "slovenščina"),
185
+ (r"\bzivjo\b", "živjo"),
186
+ ),
187
+ "nl": (
188
+ (r"\bgeinteresseerd\b", "geïnteresseerd"),
189
+ ),
190
+ "fi": (
191
+ (r"\bhyvaa\b", "hyvää"),
192
+ (r"\bpaiva\b", "päivä"),
193
+ ),
194
+ "et": (
195
+ (r"\bTonu\b", "Tõnu"),
196
+ (r"\bvoimalik\b", "võimalik"),
197
+ ),
198
+ "lv": (
199
+ (r"\bRiga\b", "Rīga"),
200
+ (r"\blatviesu\b", "latviešu"),
201
+ ),
202
+ "lt": (
203
+ (r"\blietuviu\b", "lietuvių"),
204
+ (r"\baciu\b", "ačiū"),
205
+ ),
206
+ "vi": (
207
+ (r"\bTieng Viet\b", "Tiếng Việt"),
208
+ (r"\bcam on\b", "cảm ơn"),
209
+ (r"\bViet Nam\b", "Việt Nam"),
210
+ ),
211
+ "ca": (
212
+ (r"\bcatala\b", "català"),
213
+ (r"\bcollegi\b", "col·legi"),
214
+ ),
215
+ "ga": (
216
+ (r"\bSlainte\b", "Sláinte"),
217
+ (r"\bfailte\b", "fáilte"),
218
+ ),
219
+ "mt": (
220
+ (r"\bghaliex\b", "għaliex"),
221
+ ),
222
+ "sq": (
223
+ (r"\bShqiperi\b", "Shqipëri"),
224
+ ),
225
+ }
226
+
227
+ LANGUAGE_MARKERS: dict[str, tuple[str, ...]] = {
228
+ "de": (" der ", " die ", " das ", " und ", " ist ", " nicht ", " ich ", " wir ", " mit "),
229
+ "sv": (" den ", " det ", " och ", " att ", " är ", " inte ", " jag ", " vi ", " med "),
230
+ "es": (" el ", " la ", " los ", " las ", " que ", " una ", " con ", " para "),
231
+ "fr": (" le ", " la ", " les ", " des ", " est ", " une ", " avec ", " pour "),
232
+ "pt": (" o ", " os ", " uma ", " que ", " com ", " para ", " não "),
233
+ "it": (" il ", " lo ", " gli ", " che ", " una ", " con ", " per "),
234
+ "da": (" den ", " det ", " og ", " ikke ", " jeg ", " med ", " til "),
235
+ "no": (" den ", " det ", " og ", " ikke ", " jeg ", " med ", " til "),
236
+ "is": (" og ", " ekki ", " ég ", " með ", " til ", " sem "),
237
+ "cs": (" ten ", " tato ", " a ", " že ", " není ", " jsem ", " pro "),
238
+ "sk": (" ten ", " táto ", " a ", " že ", " nie ", " som ", " pre "),
239
+ "pl": (" ten ", " ta ", " i ", " że ", " nie ", " jest ", " dla "),
240
+ "hu": (" az ", " egy ", " és ", " nem ", " van ", " hogy "),
241
+ "ro": (" un ", " o ", " și ", " nu ", " este ", " pentru "),
242
+ "tr": (" bu ", " bir ", " ve ", " değil ", " için ", " ile "),
243
+ "hr": (" ovaj ", " jedna ", " i ", " nije ", " za ", " sa "),
244
+ "bs": (" ovaj ", " jedna ", " i ", " nije ", " za ", " sa "),
245
+ "sr": (" ovaj ", " jedna ", " i ", " nije ", " za ", " sa "),
246
+ "sl": (" ta ", " ena ", " in ", " ni ", " za ", " je "),
247
+ "nl": (" de ", " het ", " een ", " en ", " niet ", " voor "),
248
+ "fi": (" tämä ", " yksi ", " ja ", " ei ", " on ", " varten "),
249
+ "et": (" see ", " üks ", " ja ", " ei ", " on ", " jaoks "),
250
+ "lv": (" tas ", " viena ", " un ", " nav ", " ir ", " par "),
251
+ "lt": (" tai ", " viena ", " ir ", " nėra ", " yra ", " už "),
252
+ "vi": (" một ", " và ", " không ", " là ", " cho ", " của "),
253
+ "ca": (" el ", " la ", " els ", " una ", " amb ", " per "),
254
+ "ga": (" an ", " na ", " agus ", " ní ", " tá ", " le "),
255
+ "mt": (" il ", " u ", " mhux ", " huwa ", " għal "),
256
+ "sq": (" ky ", " një ", " dhe ", " nuk ", " është ", " për "),
257
+ }
258
+
259
+
260
+ def mask_technical_text(text: str) -> str:
261
+ """Mask common technical spans while retaining line positions."""
262
+ patterns = (
263
+ r"```.*?```",
264
+ r"`[^`\n]+`",
265
+ r"https?://\S+",
266
+ r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b",
267
+ )
268
+ masked = text
269
+ for pattern in patterns:
270
+ masked = re.sub(
271
+ pattern,
272
+ lambda match: "".join("\n" if char == "\n" else " " for char in match.group(0)),
273
+ masked,
274
+ flags=re.DOTALL,
275
+ )
276
+ return masked
277
+
278
+
279
+ def detect_languages(text: str) -> tuple[str, ...]:
280
+ padded = f" {text.casefold()} "
281
+ scores = {
282
+ language: sum(padded.count(marker) for marker in markers)
283
+ for language, markers in LANGUAGE_MARKERS.items()
284
+ }
285
+ best = max(scores.values(), default=0)
286
+ if best == 0:
287
+ return ()
288
+ return tuple(language for language, score in scores.items() if score == best)
289
+
290
+
291
+ def iter_findings(text: str, languages: tuple[str, ...]):
292
+ for language in languages:
293
+ for pattern, suggestion in RULES[language]:
294
+ for match in re.finditer(pattern, text, flags=re.IGNORECASE):
295
+ line = text.count("\n", 0, match.start()) + 1
296
+ yield line, language, match.group(0), suggestion
297
+
298
+
299
+ def check_file(path: Path, language: str) -> int:
300
+ try:
301
+ text = path.read_text(encoding="utf-8")
302
+ except UnicodeDecodeError:
303
+ print(f"{path}: not valid UTF-8", file=sys.stderr)
304
+ return 1
305
+ except OSError as error:
306
+ print(f"{path}: {error}", file=sys.stderr)
307
+ return 1
308
+
309
+ status = 0
310
+ if text != unicodedata.normalize("NFC", text):
311
+ print(f"{path}: Unicode text is not NFC-normalized")
312
+ status = 1
313
+
314
+ prose = mask_technical_text(text)
315
+ normalized_language = language.casefold().split("-", 1)[0].split("_", 1)[0]
316
+ if language == "auto":
317
+ languages = detect_languages(prose)
318
+ elif language == "all":
319
+ languages = tuple(RULES)
320
+ elif normalized_language not in RULES:
321
+ languages = ()
322
+ print(
323
+ f"{path}: no deterministic diacritics rules for {language!r}; "
324
+ "Unicode NFC passed, but the native-orthography review is still required."
325
+ )
326
+ else:
327
+ languages = (normalized_language,)
328
+ for line, code, found, suggestion in iter_findings(prose, languages):
329
+ print(f"{path}:{line}: [{code}] {found!r} -> check {suggestion!r}")
330
+ status = 1
331
+ return status
332
+
333
+
334
+ def main() -> int:
335
+ parser = argparse.ArgumentParser(
336
+ description="Flag common missing diacritics in UTF-8 prose files."
337
+ )
338
+ parser.add_argument("files", nargs="+", type=Path)
339
+ parser.add_argument(
340
+ "--language",
341
+ default="auto",
342
+ help=(
343
+ "target language or BCP 47 tag; unsupported languages still receive "
344
+ "Unicode checks and exit successfully when clean; default: auto"
345
+ ),
346
+ )
347
+ args = parser.parse_args()
348
+
349
+ return max(check_file(path, args.language) for path in args.files)
350
+
351
+
352
+ if __name__ == "__main__":
353
+ raise SystemExit(main())
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env python3
2
+ """Zero-dependency client for the isolated BLUN Language Guard service."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import socket
8
+ from typing import Any
9
+
10
+
11
+ MAX_RESPONSE_BYTES = 8 * 1024 * 1024
12
+
13
+
14
+ class GuardServiceError(RuntimeError):
15
+ """Raised when the isolated guard cannot evaluate a request safely."""
16
+
17
+
18
+ def parse_endpoint(endpoint: str) -> tuple[str, str | tuple[str, int]]:
19
+ value = str(endpoint or "").strip()
20
+ if value.startswith("unix:") and value[5:]:
21
+ return "unix", value[5:]
22
+ if value.startswith("tcp:"):
23
+ host_port = value[4:]
24
+ host, separator, raw_port = host_port.rpartition(":")
25
+ if separator and host in {"127.0.0.1", "localhost", "::1"}:
26
+ try:
27
+ port = int(raw_port)
28
+ except ValueError as error:
29
+ raise GuardServiceError("invalid guard service TCP port") from error
30
+ if 1 <= port <= 65535:
31
+ return "tcp", (host, port)
32
+ raise GuardServiceError("guard service endpoint must be unix:/path or loopback tcp:host:port")
33
+
34
+
35
+ def call_guard_service(
36
+ endpoint: str,
37
+ request: dict[str, Any],
38
+ *,
39
+ auth_token: str = "",
40
+ timeout: float = 10.0,
41
+ ) -> dict[str, Any]:
42
+ transport, address = parse_endpoint(endpoint)
43
+ payload = dict(request)
44
+ if auth_token:
45
+ payload["service_token"] = auth_token
46
+ encoded = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
47
+ if len(encoded) > MAX_RESPONSE_BYTES:
48
+ raise GuardServiceError("guard service request is too large")
49
+ try:
50
+ if transport == "unix":
51
+ if not hasattr(socket, "AF_UNIX"):
52
+ raise GuardServiceError("Unix sockets are unavailable on this platform")
53
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
54
+ else:
55
+ client = socket.create_connection(address, timeout=timeout)
56
+ with client:
57
+ client.settimeout(timeout)
58
+ if transport == "unix":
59
+ client.connect(address)
60
+ client.sendall(encoded)
61
+ chunks: list[bytes] = []
62
+ size = 0
63
+ while True:
64
+ chunk = client.recv(65536)
65
+ if not chunk:
66
+ break
67
+ chunks.append(chunk)
68
+ size += len(chunk)
69
+ if size > MAX_RESPONSE_BYTES:
70
+ raise GuardServiceError("guard service response is too large")
71
+ if b"\n" in chunk:
72
+ break
73
+ except (OSError, TimeoutError) as error:
74
+ raise GuardServiceError("guard service is unavailable") from error
75
+ raw = b"".join(chunks).split(b"\n", 1)[0]
76
+ try:
77
+ response = json.loads(raw.decode("utf-8"))
78
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
79
+ raise GuardServiceError("guard service returned an invalid response") from error
80
+ if not isinstance(response, dict):
81
+ raise GuardServiceError("guard service response must be an object")
82
+ return response
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env python3
2
+ """Language identity, bidi, glossary, and signed release-receipt primitives."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import os
11
+ import re
12
+ import time
13
+ import unicodedata
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ VERSION = "6.20.0"
19
+ DANGEROUS_BIDI = {"\u202a", "\u202b", "\u202c", "\u202d", "\u202e"}
20
+ ISOLATE_OPENERS = {"\u2066", "\u2067", "\u2068"}
21
+ ISOLATE_CLOSER = "\u2069"
22
+ SCRIPT_RANGES = {
23
+ "Cyrl": ((0x0400, 0x052F),),
24
+ "Grek": ((0x0370, 0x03FF),),
25
+ "Arab": ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF)),
26
+ "Hebr": ((0x0590, 0x05FF),),
27
+ "Hang": ((0xAC00, 0xD7AF),),
28
+ "Hani": ((0x3400, 0x4DBF), (0x4E00, 0x9FFF)),
29
+ "Jpan": ((0x3040, 0x30FF), (0x3400, 0x9FFF)),
30
+ "Latn": ((0x0041, 0x007A), (0x00C0, 0x024F)),
31
+ }
32
+ LANGUAGE_SCRIPTS = {
33
+ "ar": "Arab", "fa": "Arab", "ur": "Arab", "he": "Hebr",
34
+ "el": "Grek", "ru": "Cyrl", "uk": "Cyrl", "bg": "Cyrl",
35
+ "mk": "Cyrl", "ko": "Hang", "ja": "Jpan", "zh": "Hani",
36
+ }
37
+
38
+
39
+ def canonical_text(text: str) -> str:
40
+ """Normalize transport-only differences without hiding character corruption."""
41
+ return unicodedata.normalize("NFC", text.removeprefix("\ufeff").replace("\r\n", "\n").replace("\r", "\n"))
42
+
43
+
44
+ def canonical_hash(text: str) -> str:
45
+ return hashlib.sha256(canonical_text(text).encode("utf-8")).hexdigest()
46
+
47
+
48
+ def _b64encode(data: bytes) -> str:
49
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
50
+
51
+
52
+ def _b64decode(data: str) -> bytes:
53
+ return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
54
+
55
+
56
+ def load_or_create_key(path: Path) -> bytes:
57
+ env_key = os.environ.get("BLUN_LANGUAGE_GUARD_KEY")
58
+ if env_key:
59
+ return hashlib.sha256(env_key.encode("utf-8")).digest()
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ if path.exists():
62
+ return path.read_bytes()
63
+ key = os.urandom(32)
64
+ temporary = path.with_suffix(".tmp")
65
+ temporary.write_bytes(key)
66
+ os.chmod(temporary, 0o600)
67
+ temporary.replace(path)
68
+ return key
69
+
70
+
71
+ def issue_receipt(
72
+ source: str, target: str, language: str, key: bytes, ttl: int = 86400,
73
+ content_type: str = "prose", short_text_reviewed: bool = False,
74
+ purpose: str = "translation",
75
+ ) -> str:
76
+ now = int(time.time())
77
+ payload = {
78
+ "v": VERSION,
79
+ "source_sha256": canonical_hash(source),
80
+ "target_sha256": canonical_hash(target),
81
+ "language": language,
82
+ "purpose": purpose,
83
+ "content_type": content_type,
84
+ "short_text_reviewed": short_text_reviewed,
85
+ "iat": now,
86
+ "exp": now + max(60, min(ttl, 604800)),
87
+ "nonce": _b64encode(os.urandom(12)),
88
+ }
89
+ encoded = _b64encode(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
90
+ signature = _b64encode(hmac.new(key, encoded.encode(), hashlib.sha256).digest())
91
+ return f"blg6.{encoded}.{signature}"
92
+
93
+
94
+ def verify_receipt(
95
+ token: str, source: str, target: str, language: str, key: bytes,
96
+ content_type: str = "prose", short_text_reviewed: bool = False,
97
+ purpose: str = "translation",
98
+ ) -> dict[str, Any]:
99
+ try:
100
+ prefix, encoded, signature = token.split(".")
101
+ expected = _b64encode(hmac.new(key, encoded.encode(), hashlib.sha256).digest())
102
+ if prefix != "blg6" or not hmac.compare_digest(signature, expected):
103
+ raise ValueError("invalid signature")
104
+ payload = json.loads(_b64decode(encoded))
105
+ checks = {
106
+ "source": payload.get("source_sha256") == canonical_hash(source),
107
+ "target": payload.get("target_sha256") == canonical_hash(target),
108
+ "language": payload.get("language") == language,
109
+ "purpose": payload.get("purpose") == purpose,
110
+ "content_type": payload.get("content_type") == content_type,
111
+ "short_text_reviewed": payload.get("short_text_reviewed") is short_text_reviewed,
112
+ "version": payload.get("v") == VERSION,
113
+ "not_expired": int(payload.get("exp", 0)) >= int(time.time()),
114
+ }
115
+ return {"valid": all(checks.values()), "checks": checks, "payload": payload}
116
+ except (ValueError, TypeError, KeyError, json.JSONDecodeError, UnicodeDecodeError) as error:
117
+ return {"valid": False, "error": str(error)}
118
+
119
+
120
+ def bidi_findings(text: str) -> list[dict[str, str]]:
121
+ findings: list[dict[str, str]] = []
122
+ stack = 0
123
+ for character in text:
124
+ if character in DANGEROUS_BIDI:
125
+ findings.append({"code": "dangerous-bidi-control", "character": f"U+{ord(character):04X}"})
126
+ elif character in ISOLATE_OPENERS:
127
+ stack += 1
128
+ elif character == ISOLATE_CLOSER:
129
+ if stack == 0:
130
+ findings.append({"code": "unpaired-bidi-isolate", "character": "U+2069"})
131
+ else:
132
+ stack -= 1
133
+ if stack:
134
+ findings.append({"code": "unclosed-bidi-isolate", "count": str(stack)})
135
+ return findings
136
+
137
+
138
+ def _in_script(character: str, script: str) -> bool:
139
+ point = ord(character)
140
+ return any(start <= point <= end for start, end in SCRIPT_RANGES.get(script, ()))
141
+
142
+
143
+ def script_report(text: str, language: str) -> dict[str, Any]:
144
+ parts = language.replace("_", "-").split("-")
145
+ base = parts[0].casefold()
146
+ explicit = next((part.title() for part in parts[1:] if len(part) == 4), None)
147
+ expected = explicit or LANGUAGE_SCRIPTS.get(base)
148
+ if not expected:
149
+ return {"status": "not-evaluated", "reason": "no deterministic script expectation"}
150
+ letters = [c for c in text if unicodedata.category(c).startswith("L")]
151
+ if not letters:
152
+ return {"status": "fail", "expected_script": expected, "ratio": 0.0}
153
+ matching = sum(_in_script(c, expected) for c in letters)
154
+ ratio = matching / len(letters)
155
+ threshold = 0.45 if expected in {"Hani", "Jpan"} else 0.70
156
+ return {"status": "pass" if ratio >= threshold else "fail", "expected_script": expected, "ratio": round(ratio, 3)}
157
+
158
+
159
+ def glossary_findings(target: str, glossary: dict[str, Any]) -> list[dict[str, str]]:
160
+ findings: list[dict[str, str]] = []
161
+ for source_term, rule in glossary.items():
162
+ if isinstance(rule, str):
163
+ pattern, flags = re.escape(rule), re.IGNORECASE
164
+ elif isinstance(rule, dict):
165
+ value = str(rule.get("target", ""))
166
+ pattern = value if rule.get("regex") else re.escape(value)
167
+ flags = 0 if rule.get("case_sensitive") else re.IGNORECASE
168
+ else:
169
+ continue
170
+ if pattern and not re.search(pattern, target, flags):
171
+ findings.append({"code": "missing-glossary-term", "source": source_term, "expected": pattern})
172
+ return findings