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.
- package/bin/launcher-mode.js +1 -0
- package/bin/launcher-runtime.js +60 -29
- package/bin/standard-tools-bootstrap.js +218 -0
- package/blun.mjs +4 -2
- package/package.json +50 -47
- package/standard-skills/blun-app-design-system/SKILL.md +30 -0
- package/standard-skills/blun-app-design-system/provenance.json +17 -0
- package/standard-skills/blun-app-design-system/references/tokens.md +48 -0
- package/standard-skills/blun-app-design-system/scripts/check-blun-design.mjs +49 -0
- package/standard-tools/language-guard/blun_language_guard.py +686 -0
- package/standard-tools/language-guard/check_diacritics.py +353 -0
- package/standard-tools/language-guard/guard_service_client.py +82 -0
- package/standard-tools/language-guard/language_quality.py +172 -0
- package/standard-tools/language-guard/translation_guard.py +916 -0
- package/standard-tools/manifest.json +76 -0
- package/skills/design-taste-frontend/SKILL.md +0 -1206
- package/skills/full-output-enforcement/SKILL.md +0 -49
- package/skills/high-end-visual-design/SKILL.md +0 -98
- package/skills/image-to-code/SKILL.md +0 -1228
- package/skills/industrial-brutalist-ui/SKILL.md +0 -92
- package/skills/minimalist-ui/SKILL.md +0 -85
- package/skills/motion-design-taste/SKILL.md +0 -74
- package/skills/premortem/SKILL.md +0 -148
- package/skills/redesign-existing-projects/SKILL.md +0 -178
- package/skills/screenshot-lesen/SKILL.md +0 -52
- package/skills/stitch-design-taste/DESIGN.md +0 -121
- package/skills/stitch-design-taste/SKILL.md +0 -184
- 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
|