@andespindola/crow-skills 0.12.1 → 0.13.0

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.
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env python3
2
+ """Confere se uma versão comprimida de um texto preservou o que muda o sentido.
3
+
4
+ Uso: lc_fidelity.py ORIGINAL COMPRIMIDO
5
+ Saída: 0 = aprovado; 1 = reprovado (lista o que se perdeu, inverteu ou foi inventado); 2 = erro de uso.
6
+
7
+ O que reprova:
8
+ - família lógica perdida, inventada ou trocada: negação (inclui "must not", "não deve"), obrigação,
9
+ recomendação, permissão, "sempre", "só", "exceto", limites máximo/mínimo, ordem (antes/depois).
10
+ Sinônimos dentro da família são aceitos (must not = never; nunca = jamais).
11
+ - alvo deslocado: o que estava negado, restrito, excetuado ou ordenado mudou ("never npm" → "never pnpm",
12
+ "run tests before deploy" → "deploy before run tests").
13
+ - papéis trocados: dois termos trocaram de contexto ("enabled in prod, disabled in dev" invertido;
14
+ "read allowed, write forbidden" invertido; números trocados entre rótulos).
15
+ - números, datas, versões, URLs, caminhos, nomes de arquivo, flags e `código inline` perdidos ou inventados
16
+ (normaliza 1,000 = 1000 e 5 GB = 5GB);
17
+ - blocos de código (``` , ~~~ ou indentados após linha em branco) alterados;
18
+ - conteúdo removido demais: menos de 35% das palavras de conteúdo (por radical) preservadas.
19
+ Entre 35% e 60% sai só um AVISO.
20
+ É uma checagem de melhor esforço, determinística e local; aprovar não dispensa ler o resultado.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import collections
25
+ import itertools
26
+ import re
27
+ import sys
28
+
29
+ FAMILIES = {
30
+ "negação": ["must not", "mustn't", "must never", "should not", "shouldn't", "shall not", "may not", "do not",
31
+ "does not", "did not", "is not", "are not", "was not", "cannot", "can't", "won't", "don't", "doesn't",
32
+ "didn't", "isn't", "aren't", "wasn't", "not", "never", "none", "nor", "without", "avoid", "forbidden",
33
+ "prohibited", "no",
34
+ "não deve", "não devem", "não pode", "não podem", "não", "nao", "nunca", "jamais", "nenhum", "nenhuma",
35
+ "nem", "sem", "evite", "evitar", "proibido", "proibida", "vedado"],
36
+ "obrigação": ["deve", "devem", "deverá", "precisa", "precisam", "obrigatório", "obrigatória", "obrigatoriamente",
37
+ "must", "shall", "required", "require", "requires", "mandatory"],
38
+ "recomendação": ["deveria", "deveriam", "recomendado", "recomenda-se", "should", "recommended", "ideally"],
39
+ "permissão": ["pode", "podem", "opcional", "opcionalmente", "permitido", "permitida", "may", "optional",
40
+ "optionally", "allowed", "permitted"],
41
+ "sempre": ["sempre", "toda vez", "always", "every time"],
42
+ "restrição": ["só", "somente", "apenas", "unicamente", "exclusivamente", "only", "solely", "exclusively"],
43
+ "exceção": ["a menos que", "a não ser que", "exceto", "salvo", "except", "unless", "excluding"],
44
+ "limite máximo": ["no máximo", "até no máximo", "at most", "up to", "no more than", "max", "máx", "maximum", "máximo"],
45
+ "limite mínimo": ["pelo menos", "no mínimo", "at least", "no less than", "min", "mín", "minimum", "mínimo"],
46
+ "antes": ["antes de", "antes do", "antes da", "antes que", "before", "prior to"],
47
+ "depois": ["depois de", "depois do", "depois da", "depois que", "após", "after", "afterwards"],
48
+ }
49
+ SCOPED = ("negação", "restrição", "exceção", "antes", "depois")
50
+ STOP = set("""a o os as um uma uns umas de do da dos das em no na nos nas por para com e ou que se ao aos à às é são
51
+ seu sua seus suas você vocês eu nós isso este esta esse essa
52
+ the a an of to in on at for with and or is are be by it this that these those you your we our i me my they them
53
+ their its as so if do does then than also just""".split())
54
+
55
+ _CODE_FENCE = re.compile(r"^(```|~~~)[^\n]*\n.*?^\1[ \t]*$", re.S | re.M)
56
+ _INLINE = re.compile(r"`[^`\n]+`")
57
+ _URL = re.compile(r"https?://[^\s)>\]\"']+")
58
+ _PATH = re.compile(r"(?<![\w.])(?:~|\.{1,2})?/[\w.@~-]+(?:/[\w.@~-]+)+")
59
+ _FILE = re.compile(r"(?<![\w/.-])(?:\.[A-Za-z][\w-]{1,30}|[\w-]+\.(?:ya?ml|json|toml|ini|env|md|txt|py|js|ts|tsx|jsx|go|rs|rb|java|kt|sh|sql|lock|cfg|conf|xml|html|css|csv|log))(?![\w])")
60
+ _FRAMEWORK = re.compile(r"^[A-Z][a-z]+\.js$") # Node.js, Vue.js, Next.js são nomes, não arquivos
61
+ _FLAG = re.compile(r"(?<![\w-])--?[A-Za-z][\w-]*(?:=\S+)?")
62
+ _DATE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b|\b\d{1,2}/\d{1,2}/\d{2,4}\b|\b\d{1,2} de [a-zç]+ de \d{4}\b|\b[A-Z][a-z]+ \d{1,2}, \d{4}\b", re.I)
63
+ _VERSION = re.compile(r"\bv?\d+\.\d+\.\d+(?:[-+][\w.]+)?\b|\bv\d+(?:\.\d+)?\b")
64
+ _NUMBER = re.compile(r"(?<![\w.])-?\d{1,3}(?:[.,]\d{3})+(?![\d])|(?<![\w.])-?\d+(?:[.,]\d+)?(?:\s?(?:%|ms|s|min|h|kb|mb|gb|tb|px|rem|em|x|k)\b)?", re.I)
65
+ _WORD = re.compile(r"[A-Za-zÀ-ÿ][\w'-]*")
66
+ _SENT = re.compile(r"[.;:!?]+(?=\s|$)|\n+")
67
+
68
+
69
+ def norm_text(t: str) -> str:
70
+ return t.replace("’", "'").replace("‘", "'").replace("“", '"').replace("”", '"')
71
+
72
+
73
+ def norm_number(n: str) -> str:
74
+ n = n.strip().lower().replace(" ", "")
75
+ if re.fullmatch(r"-?\d{1,3}(?:[.,]\d{3})+", n):
76
+ return re.sub(r"[.,]", "", n)
77
+ return n.replace(",", ".")
78
+
79
+
80
+ def stem(w: str) -> str:
81
+ w = w.lower().strip("'-")
82
+ for suf in ("ações", "ação", "ções", "ção", "mente", "ing", "ies", "ied", "ed", "es", "s"):
83
+ if len(w) > len(suf) + 3 and w.endswith(suf):
84
+ return w[: -len(suf)]
85
+ return w
86
+
87
+
88
+ def is_portuguese(t: str) -> bool:
89
+ w = [x.lower() for x in _WORD.findall(t)]
90
+ pt = sum(x in ("de", "que", "não", "para", "com", "uma", "os", "do", "da", "em", "é") for x in w)
91
+ en = sum(x in ("the", "and", "to", "of", "is", "with", "for", "this", "that", "it") for x in w)
92
+ return pt > en
93
+
94
+
95
+ def indented_blocks(text: str):
96
+ """Blocos indentados de código: só depois de linha em branco e fora de lista (continuação de bullet não conta)."""
97
+ lines = text.split("\n")
98
+ blocks, keep = [], []
99
+ in_block = False
100
+ last_nonblank = ""
101
+ for i, line in enumerate(lines):
102
+ indented = bool(re.match(r"(?: {4}|\t)\S", line))
103
+ prev_blank = i > 0 and lines[i - 1].strip() == ""
104
+ if indented and (in_block or (prev_blank and not re.match(r"\s*([-*+]|\d+[.)])\s", last_nonblank))):
105
+ in_block = True
106
+ blocks.append(line.rstrip())
107
+ keep.append("")
108
+ continue
109
+ in_block = False
110
+ keep.append(line)
111
+ if line.strip():
112
+ last_nonblank = line
113
+ return blocks, "\n".join(keep)
114
+
115
+
116
+ def family_hits(prose: str, pt: bool):
117
+ """(família, início, fim, termo) sem sobreposição, preferindo termos mais longos."""
118
+ terms = sorted(((t, f) for f, ts in FAMILIES.items() for t in ts), key=lambda x: -len(x[0]))
119
+ taken = bytearray(len(prose) + 1)
120
+ hits = []
121
+ low = prose.lower()
122
+ for term, fam in terms:
123
+ if term == "no" and pt:
124
+ continue # "no" em português é "em + o"
125
+ for m in re.finditer(r"(?<![\w'-])" + re.escape(term) + r"(?![\w'-])", low):
126
+ if any(taken[m.start():m.end()]):
127
+ continue
128
+ if term in ("min", "max", "mín", "máx") and re.search(r"\d\s?$", low[:m.start()]):
129
+ continue # "5 min" é unidade de tempo
130
+ if term == "up to" and low[m.end():m.end() + 5] == " date":
131
+ continue # "up to date"
132
+ for i in range(m.start(), m.end()):
133
+ taken[i] = 1
134
+ hits.append((fam, m.start(), m.end(), term))
135
+ return sorted(hits, key=lambda h: h[1])
136
+
137
+
138
+ def content(s: str) -> list:
139
+ return [stem(w) for w in _WORD.findall(s) if w.lower() not in STOP and len(w) > 2]
140
+
141
+
142
+ _FAM_STEMS = {stem(w) for ts in FAMILIES.values() for t in ts for w in t.split()}
143
+
144
+
145
+ POSTFIX = {"forbidden", "prohibited", "allowed", "permitted", "required", "mandatory", "optional", "proibido",
146
+ "proibida", "permitido", "permitida", "obrigatório", "obrigatória", "opcional", "vedado"}
147
+
148
+
149
+ def scopes(prose: str, hits):
150
+ """Alvo de cada termo com escopo: até 4 palavras de conteúdo seguintes, na mesma oração.
151
+ Termos pós-fixados ("write is forbidden") usam as palavras anteriores como alvo."""
152
+ out = []
153
+ for fam, s, e, term in hits:
154
+ if term in POSTFIX:
155
+ clause = _SENT.split(prose[max(0, s - 120):s])[-1]
156
+ words = [w for w in content(clause) if w not in _FAM_STEMS][-4:]
157
+ if words:
158
+ out.append((fam + "·sujeito", term, words))
159
+ continue
160
+ if fam not in SCOPED:
161
+ continue
162
+ clause = _SENT.split(prose[e:e + 120])[0]
163
+ words = [w for w in content(clause) if w not in _FAM_STEMS][:4]
164
+ if words:
165
+ out.append((fam, term, words))
166
+ return out
167
+
168
+
169
+ def neighbors(prose: str) -> dict:
170
+ """Para cada radical que aparece uma vez: as 2 palavras de conteúdo seguintes na mesma oração."""
171
+ seq = [[w for w in content(c) if w not in _FAM_STEMS] for c in _SENT.split(prose)]
172
+ counts = collections.Counter(w for ws in seq for w in ws)
173
+ nb = {}
174
+ for ws in seq:
175
+ for i, w in enumerate(ws):
176
+ if counts[w] == 1:
177
+ nb[w] = set(ws[i + 1:i + 3])
178
+ return nb
179
+
180
+
181
+ def labeled_numbers(prose: str):
182
+ """Rótulos de cada número: palavra de conteúdo imediatamente antes e imediatamente depois, na mesma oração."""
183
+ out = []
184
+ for m in _NUMBER.finditer(prose):
185
+ before = re.split(r"[.,;:\n]", prose[max(0, m.start() - 80):m.start()])[-1]
186
+ after = re.split(r"[.,;:\n]", prose[m.end():m.end() + 80])[0]
187
+ b, a = content(before), content(after)
188
+ labels = set(b[-1:]) | set(a[:1]) | set(b[:1])
189
+ if labels:
190
+ out.append((norm_number(m.group(0)), labels))
191
+ return out
192
+
193
+
194
+ def extract(text: str):
195
+ text = norm_text(text)
196
+ fences = [m.group(0).strip() for m in _CODE_FENCE.finditer(text)]
197
+ no_fence = _CODE_FENCE.sub("\n", text)
198
+ indented, prose = indented_blocks(no_fence)
199
+ inline = _INLINE.findall(prose)
200
+ prose_ni = _INLINE.sub(" ", prose)
201
+ urls = _URL.findall(prose_ni)
202
+ rest = _URL.sub(" ", prose_ni)
203
+ paths = _PATH.findall(rest)
204
+ files = [f for f in _FILE.findall(_PATH.sub(" ", rest)) if not _FRAMEWORK.match(f)]
205
+ flags = _FLAG.findall(rest)
206
+ dates = _DATE.findall(rest)
207
+ rest2 = _DATE.sub(" ", rest)
208
+ versions = _VERSION.findall(rest2)
209
+ rest3 = _VERSION.sub(" ", _FLAG.sub(" ", rest2))
210
+ numbers = [norm_number(n) for n in _NUMBER.findall(rest3)]
211
+ pt = is_portuguese(prose_ni)
212
+ hits = family_hits(prose_ni, pt)
213
+ return dict(
214
+ exact={
215
+ "blocos de código": collections.Counter(fences + indented),
216
+ "código inline": collections.Counter(inline),
217
+ "URLs": collections.Counter(urls),
218
+ "caminhos": collections.Counter(paths),
219
+ "arquivos": collections.Counter(f.lower() for f in files),
220
+ "flags": collections.Counter(flags),
221
+ "datas": collections.Counter(d.lower() for d in dates),
222
+ "versões": collections.Counter(versions),
223
+ "números": collections.Counter(numbers),
224
+ },
225
+ families=collections.Counter(h[0] for h in hits),
226
+ scopes=scopes(prose_ni, hits),
227
+ neighbors=neighbors(rest),
228
+ labeled=labeled_numbers(rest3),
229
+ words=content(prose_ni),
230
+ )
231
+
232
+
233
+ def compare(a: str, b: str):
234
+ A, B = extract(a), extract(b)
235
+ problems, warnings = [], []
236
+ for kind, ca in A["exact"].items():
237
+ lost = ca - B["exact"][kind]
238
+ if lost:
239
+ problems.append(f"PERDIDO [{kind}]: " + ", ".join(repr(k) for k in list(lost)[:12]))
240
+ if kind != "blocos de código":
241
+ extra = B["exact"][kind] - ca
242
+ if extra:
243
+ problems.append(f"INVENTADO [{kind}]: " + ", ".join(repr(k) for k in list(extra)[:12]))
244
+ for fam in FAMILIES:
245
+ na, nb = A["families"][fam], B["families"][fam]
246
+ if nb < na:
247
+ problems.append(f"PERDIDO [{fam}]: {na} no original, {nb} na versão comprimida")
248
+ elif nb > na:
249
+ problems.append(f"INVENTADO [{fam}]: {na} no original, {nb} na versão comprimida")
250
+ # alvo deslocado: o alvo ainda existe no texto, mas não está mais sob o mesmo operador
251
+ bwords = set(B["words"])
252
+ for fam, term, words in A["scopes"]:
253
+ key = [w for w in words if w in bwords]
254
+ if not key:
255
+ continue
256
+ need = min(2, len(key))
257
+ if not any(f == fam and len(set(key) & set(ws)) >= need for f, _, ws in B["scopes"]):
258
+ problems.append(f"DESLOCADO [{fam}]: o alvo de '{term}' ({' '.join(words)}) mudou")
259
+ # papéis trocados: x e y trocaram de vizinhos
260
+ na_, nb_ = A["neighbors"], B["neighbors"]
261
+ common = [w for w in na_ if w in nb_][:600]
262
+ for x, y in itertools.combinations(common, 2):
263
+ ax, ay, bx, by = na_[x], na_[y], nb_[x], nb_[y]
264
+ ux, uy = ax - ay, ay - ax
265
+ if ux and uy and (ux & by) and (uy & bx) and not (ux & bx) and not (uy & by):
266
+ problems.append(f"TROCADO [papéis]: '{x}' e '{y}' trocaram de contexto")
267
+ # números trocados de rótulo: o rótulo de n agora acompanha outro número que já existia no original
268
+ nums_a = {n for n, _ in A["labeled"]}
269
+ for n_a, la in A["labeled"]:
270
+ others = set().union(*[l for n, l in A["labeled"] if n != n_a]) if len(A["labeled"]) > 1 else set()
271
+ own = la - others # rótulos que só este número tem
272
+ if not own:
273
+ continue
274
+ if any(n_b == n_a and (lb & own) for n_b, lb in B["labeled"]):
275
+ continue
276
+ for n_b, lb in B["labeled"]:
277
+ if n_b != n_a and n_b in nums_a and (lb & own):
278
+ problems.append(f"TROCADO [números]: '{' '.join(sorted(lb & own))}' tinha {n_a}, passou a ter {n_b}")
279
+ problems = list(dict.fromkeys(problems))
280
+ wa, wb = set(A["words"]), set(B["words"])
281
+ recall = len(wa & wb) / len(wa) if wa else 1.0
282
+ if recall < 0.35:
283
+ problems.append(f"CONTEÚDO REMOVIDO: só {recall:.0%} das palavras de conteúdo foram preservadas")
284
+ elif recall < 0.6:
285
+ warnings.append(f"AVISO: {recall:.0%} das palavras de conteúdo preservadas; confira se nenhuma instrução sumiu")
286
+ return problems, warnings, recall
287
+
288
+
289
+ def read(path: str) -> str:
290
+ with open(path, "rb") as f:
291
+ raw = f.read()
292
+ try:
293
+ return raw.decode("utf-8")
294
+ except UnicodeDecodeError:
295
+ return raw.decode("latin-1")
296
+
297
+
298
+ def main(argv: list) -> int:
299
+ if len(argv) != 2:
300
+ sys.stderr.write(__doc__)
301
+ return 2
302
+ try:
303
+ a, b = read(argv[0]), read(argv[1])
304
+ except OSError as e:
305
+ sys.stderr.write(f"lc_fidelity: não consegui ler: {e}\n")
306
+ return 2
307
+ problems, warnings, recall = compare(a, b)
308
+ for p in problems + warnings:
309
+ print(p)
310
+ ratio = len(b) / len(a) if a else 1
311
+ print(f"tamanho: {len(a)} → {len(b)} caracteres ({ratio:.0%} do original); palavras de conteúdo preservadas: {recall:.0%}")
312
+ if problems:
313
+ print("resultado: REPROVADO — refaça a compressão preservando os itens acima")
314
+ return 1
315
+ print("resultado: aprovado (nada crítico perdido, invertido ou inventado)")
316
+ return 0
317
+
318
+
319
+ if __name__ == "__main__":
320
+ sys.exit(main(sys.argv[1:]))