@allansantos-dev/smart-tool 0.9.40 → 0.9.42
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/CHANGELOG.md +43 -0
- package/README.md +11 -7
- package/client_hooks.py +94 -31
- package/config.py +1 -1
- package/dependency_notice.py +136 -32
- package/document_text.py +274 -47
- package/freshness.py +16 -10
- package/hook_decision.py +9 -7
- package/hooks/session_audit.py +1 -1
- package/index_profile.py +1 -1
- package/index_scope.py +9 -1
- package/indexer.py +4 -2
- package/model_breaker.py +70 -0
- package/model_client.py +17 -10
- package/model_health.py +46 -4
- package/package.json +1 -1
- package/project_monitor.py +17 -2
- package/scout.py +1 -1
- package/setup_ui.py +55 -22
- package/smart_tool_daemon.py +120 -64
- package/usage_meter.py +13 -0
- package/version.py +1 -1
- package/web_fetch.py +7 -7
package/document_text.py
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
"""Extração local de texto DOCX e PDF, sem Office, execução de macros ou acesso externo."""
|
|
1
|
+
"""Extração local de texto DOCX, PPTX, XLSX e PDF, sem Office, execução de macros ou acesso externo."""
|
|
2
|
+
import contextlib
|
|
2
3
|
import io
|
|
3
4
|
import json
|
|
5
|
+
import posixpath
|
|
4
6
|
import re
|
|
5
7
|
import subprocess
|
|
6
8
|
import sys
|
|
7
9
|
import zipfile
|
|
8
10
|
import xml.etree.ElementTree as ET
|
|
11
|
+
import zlib
|
|
9
12
|
|
|
10
|
-
VERSION =
|
|
13
|
+
VERSION = 3
|
|
11
14
|
PDF_MAX_BYTES = 20 * 1024 * 1024
|
|
12
15
|
PDF_TIMEOUT_S = 20
|
|
13
16
|
PDF_MAX_PAGES = 300
|
|
@@ -15,6 +18,8 @@ PDF_MAX_CHARS = 1_000_000
|
|
|
15
18
|
MAX_ARCHIVE_BYTES = 10 * 1024 * 1024
|
|
16
19
|
MAX_XML_BYTES = 8 * 1024 * 1024
|
|
17
20
|
MAX_TEXT_BYTES = 1024 * 1024
|
|
21
|
+
XLSX_MAX_ROWS = 100_000
|
|
22
|
+
XLSX_MAX_COLUMNS = 16_384
|
|
18
23
|
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
|
19
24
|
|
|
20
25
|
|
|
@@ -37,53 +42,264 @@ class PdfUnreadable(DocumentError):
|
|
|
37
42
|
|
|
38
43
|
|
|
39
44
|
class _DocumentTreeBuilder(ET.TreeBuilder):
|
|
45
|
+
label = 'DOCX'
|
|
46
|
+
|
|
40
47
|
def doctype(self, name, pubid, system):
|
|
41
48
|
# O callback também recusa DTD em XML UTF-16, antes de expandir entidades.
|
|
42
|
-
raise DocumentError('
|
|
49
|
+
raise DocumentError(f'{self.label} contains disallowed entities.')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Package:
|
|
53
|
+
"""An open OOXML zip: the parts it holds and a guarded parser that charges every part to one XML budget."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, archive, entries, label):
|
|
56
|
+
self.archive = archive
|
|
57
|
+
self.entries = entries
|
|
58
|
+
self.label = label
|
|
59
|
+
self.xml_bytes = 0
|
|
60
|
+
|
|
61
|
+
def parse(self, name):
|
|
62
|
+
info = self.entries[name]
|
|
63
|
+
if info.flag_bits & 1 or info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
|
|
64
|
+
raise DocumentError(f'Encrypted {self.label} or unsupported compression.')
|
|
65
|
+
if info.file_size > MAX_XML_BYTES - self.xml_bytes:
|
|
66
|
+
raise DocumentError(f'{self.label} XML content over the limit.')
|
|
67
|
+
with self.archive.open(info) as stream:
|
|
68
|
+
raw = stream.read(MAX_XML_BYTES - self.xml_bytes + 1)
|
|
69
|
+
self.xml_bytes += len(raw)
|
|
70
|
+
if self.xml_bytes > MAX_XML_BYTES or b'<!DOCTYPE' in raw.upper() or b'<!ENTITY' in raw.upper():
|
|
71
|
+
raise DocumentError(f'{self.label} contains excessive XML or disallowed entities.')
|
|
72
|
+
builder = _DocumentTreeBuilder()
|
|
73
|
+
builder.label = self.label
|
|
74
|
+
return ET.fromstring(raw, parser=ET.XMLParser(target=builder))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@contextlib.contextmanager
|
|
78
|
+
def _open_package(data, label, required):
|
|
79
|
+
"""The one place that opens an OOXML zip (docx, pptx, xlsx) with the guards: archive size, entry count, a required
|
|
80
|
+
part, no encryption, one XML budget, no DOCTYPE or ENTITY, and every low-level failure turned into a DocumentError.
|
|
81
|
+
|
|
82
|
+
Parameters: data (zip bytes), label (`DOCX`, `PPTX` or `XLSX`, used in the reasons), required (a part name, or a
|
|
83
|
+
predicate over the part names, that must exist). Yields: a _Package. Raises: DocumentError with a readable reason."""
|
|
84
|
+
if len(data) > MAX_ARCHIVE_BYTES:
|
|
85
|
+
raise DocumentError(f'{label} over the 10 MiB limit.')
|
|
86
|
+
try:
|
|
87
|
+
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
88
|
+
listing = archive.infolist()
|
|
89
|
+
entries = {info.filename: info for info in listing}
|
|
90
|
+
present = required(entries) if callable(required) else required in entries
|
|
91
|
+
if len(listing) > 2000 or not present:
|
|
92
|
+
raise DocumentError(f'Invalid {label} or excessive structure.')
|
|
93
|
+
yield _Package(archive, entries, label)
|
|
94
|
+
except (zipfile.BadZipFile, ET.ParseError, RuntimeError, NotImplementedError, OSError, zlib.error, EOFError) as exc:
|
|
95
|
+
raise DocumentError(f'Could not extract text from this {label}.') from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _Lines(list):
|
|
99
|
+
"""Output lines of an extractor; `add` refuses to grow past MAX_TEXT_BYTES of UTF-8 text."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, label):
|
|
102
|
+
super().__init__()
|
|
103
|
+
self.label = label
|
|
104
|
+
self.size = 0
|
|
105
|
+
|
|
106
|
+
def add(self, *lines):
|
|
107
|
+
for line in lines:
|
|
108
|
+
self.size += len(line.encode('utf-8')) + 1
|
|
109
|
+
if self.size > MAX_TEXT_BYTES:
|
|
110
|
+
raise DocumentError(f'Text extracted from {self.label} exceeds 1 MiB.')
|
|
111
|
+
self.append(line)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _local(tag):
|
|
115
|
+
return tag.rsplit('}', 1)[-1]
|
|
43
116
|
|
|
44
117
|
|
|
45
118
|
def extract_docx(data):
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
119
|
+
"""Text of a .docx: body, headers, footers, footnotes and endnotes, one line per non-empty paragraph.
|
|
120
|
+
|
|
121
|
+
Parameters: data (the file bytes). Returns: UTF-8 text bytes. Raises: DocumentError with a readable reason for a
|
|
122
|
+
corrupt, encrypted or oversized package, or one with a DOCTYPE or entity."""
|
|
123
|
+
with _open_package(data, 'DOCX', 'word/document.xml') as package:
|
|
124
|
+
names = ['word/document.xml'] + sorted(name for name in package.entries if re.fullmatch(
|
|
125
|
+
r'word/(?:header\d+|footer\d+|footnotes|endnotes)\.xml', name))
|
|
126
|
+
paragraphs = _Lines('DOCX')
|
|
127
|
+
for name in names:
|
|
128
|
+
root = package.parse(name)
|
|
129
|
+
namespace = W if root.tag.startswith(W) else '{http://purl.oclc.org/ooxml/wordprocessingml/main}'
|
|
130
|
+
for paragraph in root.iter(namespace + 'p'):
|
|
131
|
+
def parts(node):
|
|
132
|
+
pending = list(reversed(node))
|
|
133
|
+
while pending:
|
|
134
|
+
child = pending.pop()
|
|
135
|
+
if child.tag == namespace + 'p':
|
|
136
|
+
continue
|
|
137
|
+
if child.tag == namespace + 't':
|
|
138
|
+
yield child.text or ''
|
|
139
|
+
elif child.tag == namespace + 'tab':
|
|
140
|
+
yield '\t'
|
|
141
|
+
elif child.tag in (namespace + 'br', namespace + 'cr'):
|
|
142
|
+
yield '\n'
|
|
143
|
+
else:
|
|
144
|
+
pending.extend(reversed(child))
|
|
145
|
+
text = ''.join(parts(paragraph))
|
|
146
|
+
if text.strip():
|
|
147
|
+
paragraphs.add(text)
|
|
148
|
+
return ('\n'.join(paragraphs) + '\n' if paragraphs else '').encode('utf-8')
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _drawing_paragraphs(root):
|
|
152
|
+
"""Non-empty paragraphs (`a:p`) of a slide or notes part, from the `a:t` runs; the slide-number field is not text."""
|
|
153
|
+
lines = []
|
|
154
|
+
for paragraph in root.iter():
|
|
155
|
+
if _local(paragraph.tag) != 'p':
|
|
156
|
+
continue
|
|
157
|
+
pending = list(reversed(paragraph))
|
|
158
|
+
pieces = []
|
|
159
|
+
while pending:
|
|
160
|
+
child = pending.pop()
|
|
161
|
+
kind = _local(child.tag)
|
|
162
|
+
if kind == 'fld' and child.get('type', '').startswith('slidenum'):
|
|
163
|
+
continue
|
|
164
|
+
if kind == 't':
|
|
165
|
+
pieces.append(child.text or '')
|
|
166
|
+
elif kind == 'br':
|
|
167
|
+
pieces.append('\n')
|
|
168
|
+
elif kind != 'p':
|
|
169
|
+
pending.extend(reversed(child))
|
|
170
|
+
text = ''.join(pieces)
|
|
171
|
+
if text.strip():
|
|
172
|
+
lines.append(text)
|
|
173
|
+
return lines
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def extract_pptx(data):
|
|
177
|
+
"""Text of a .pptx: each slide under `[slide N]`, then its speaker notes under `[notes N]`, in slide order.
|
|
178
|
+
|
|
179
|
+
Parameters: data (the file bytes). Returns: UTF-8 text bytes. Raises: DocumentError with a readable reason for a
|
|
180
|
+
corrupt, encrypted, oversized or hostile package, or a deck without any text."""
|
|
181
|
+
def has_slides(names):
|
|
182
|
+
return any(re.fullmatch(r'ppt/slides/slide\d+\.xml', name) for name in names)
|
|
183
|
+
|
|
184
|
+
with _open_package(data, 'PPTX', has_slides) as package:
|
|
185
|
+
slides = sorted((int(m.group(1)), name) for name in package.entries
|
|
186
|
+
if (m := re.fullmatch(r'ppt/slides/slide(\d+)\.xml', name)))
|
|
187
|
+
lines = _Lines('PPTX')
|
|
188
|
+
for number, name in slides:
|
|
189
|
+
body = _drawing_paragraphs(package.parse(name))
|
|
190
|
+
if body:
|
|
191
|
+
lines.add(f'[slide {number}]', *body)
|
|
192
|
+
notes_name = f'ppt/notesSlides/notesSlide{number}.xml'
|
|
193
|
+
notes = _drawing_paragraphs(package.parse(notes_name)) if notes_name in package.entries else []
|
|
194
|
+
if notes:
|
|
195
|
+
lines.add(f'[notes {number}]', *notes)
|
|
196
|
+
if not lines:
|
|
197
|
+
raise DocumentError('PPTX has no text.')
|
|
198
|
+
return ('\n'.join(lines) + '\n').encode('utf-8')
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _shared_strings(package):
|
|
202
|
+
if 'xl/sharedStrings.xml' not in package.entries:
|
|
203
|
+
return []
|
|
204
|
+
strings = []
|
|
205
|
+
for item in package.parse('xl/sharedStrings.xml'):
|
|
206
|
+
if _local(item.tag) != 'si':
|
|
207
|
+
continue
|
|
208
|
+
pending = list(reversed(item))
|
|
209
|
+
pieces = []
|
|
210
|
+
while pending:
|
|
211
|
+
child = pending.pop()
|
|
212
|
+
kind = _local(child.tag)
|
|
213
|
+
if kind == 't':
|
|
214
|
+
pieces.append(child.text or '')
|
|
215
|
+
elif kind != 'rPh':
|
|
216
|
+
pending.extend(reversed(child))
|
|
217
|
+
strings.append(''.join(pieces))
|
|
218
|
+
return strings
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _column_index(ref):
|
|
222
|
+
letters = re.match(r'[A-Za-z]+', ref or '')
|
|
223
|
+
if not letters:
|
|
224
|
+
return None
|
|
225
|
+
index = 0
|
|
226
|
+
for char in letters.group().upper():
|
|
227
|
+
index = index * 26 + ord(char) - 64
|
|
228
|
+
return index - 1
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _sheet_rows(root, strings):
|
|
232
|
+
"""Tab-joined text of each non-empty row of a worksheet; an empty cell keeps the columns after it aligned."""
|
|
233
|
+
for row in root.iter():
|
|
234
|
+
if _local(row.tag) != 'row':
|
|
235
|
+
continue
|
|
236
|
+
values = []
|
|
237
|
+
for cell in row:
|
|
238
|
+
if _local(cell.tag) != 'c':
|
|
239
|
+
continue
|
|
240
|
+
kind = cell.get('t')
|
|
241
|
+
value = ''
|
|
242
|
+
if kind == 'inlineStr':
|
|
243
|
+
value = ''.join(node.text or '' for node in cell.iter() if _local(node.tag) == 't')
|
|
244
|
+
else:
|
|
245
|
+
raw = next((node.text or '' for node in cell if _local(node.tag) == 'v'), '')
|
|
246
|
+
if kind == 's':
|
|
247
|
+
position = int(raw) if raw.strip().isdigit() else -1
|
|
248
|
+
if not 0 <= position < len(strings):
|
|
249
|
+
raise DocumentError('Could not extract text from this XLSX.')
|
|
250
|
+
value = strings[position]
|
|
251
|
+
else:
|
|
252
|
+
value = raw
|
|
253
|
+
column = _column_index(cell.get('r'))
|
|
254
|
+
if column is None:
|
|
255
|
+
column = len(values)
|
|
256
|
+
if column >= XLSX_MAX_COLUMNS:
|
|
257
|
+
raise DocumentError(f'XLSX over {XLSX_MAX_COLUMNS} columns.')
|
|
258
|
+
values += [''] * (column - len(values))
|
|
259
|
+
if column == len(values):
|
|
260
|
+
values.append(value)
|
|
261
|
+
else:
|
|
262
|
+
values[column] = value
|
|
263
|
+
if any(value.strip() for value in values):
|
|
264
|
+
yield '\t'.join(values)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def extract_xlsx(data):
|
|
268
|
+
"""Text of an .xlsx: each sheet, in workbook order, under `[sheet NAME]`, then one line per row with the cells
|
|
269
|
+
separated by tabs. Shared strings are resolved; numbers and inline strings are kept; a formula contributes its cached
|
|
270
|
+
value, nothing is calculated.
|
|
271
|
+
|
|
272
|
+
Parameters: data (the file bytes). Returns: UTF-8 text bytes. Raises: DocumentError with a readable reason for a
|
|
273
|
+
corrupt, encrypted, oversized or hostile package, a workbook over XLSX_MAX_ROWS rows or MAX_TEXT_BYTES of text, or
|
|
274
|
+
one without any text."""
|
|
275
|
+
with _open_package(data, 'XLSX', 'xl/workbook.xml') as package:
|
|
276
|
+
workbook = package.parse('xl/workbook.xml')
|
|
277
|
+
targets = {}
|
|
278
|
+
if 'xl/_rels/workbook.xml.rels' in package.entries:
|
|
279
|
+
for rel in package.parse('xl/_rels/workbook.xml.rels'):
|
|
280
|
+
target = rel.get('Target', '')
|
|
281
|
+
targets[rel.get('Id')] = posixpath.normpath(target.lstrip('/') if target.startswith('/')
|
|
282
|
+
else posixpath.join('xl', target))
|
|
283
|
+
strings = _shared_strings(package)
|
|
284
|
+
lines = _Lines('XLSX')
|
|
285
|
+
rows = 0
|
|
286
|
+
for sheet in (node for node in workbook.iter() if _local(node.tag) == 'sheet'):
|
|
287
|
+
rid = next((value for key, value in sheet.attrib.items() if _local(key) == 'id'), None)
|
|
288
|
+
part = targets.get(rid)
|
|
289
|
+
if part not in package.entries:
|
|
290
|
+
raise DocumentError('Invalid XLSX or excessive structure.')
|
|
291
|
+
header = f"[sheet {' '.join(sheet.get('name', '').split())}]"
|
|
292
|
+
for line in _sheet_rows(package.parse(part), strings):
|
|
293
|
+
rows += 1
|
|
294
|
+
if rows > XLSX_MAX_ROWS:
|
|
295
|
+
raise DocumentError(f'XLSX over {XLSX_MAX_ROWS} rows.')
|
|
296
|
+
if header:
|
|
297
|
+
lines.add(header)
|
|
298
|
+
header = ''
|
|
299
|
+
lines.add(line)
|
|
300
|
+
if not lines:
|
|
301
|
+
raise DocumentError('XLSX has no text.')
|
|
302
|
+
return ('\n'.join(lines) + '\n').encode('utf-8')
|
|
87
303
|
|
|
88
304
|
|
|
89
305
|
PDF_SCRIPT = """
|
|
@@ -150,11 +366,15 @@ def extract_pdf(data):
|
|
|
150
366
|
return (text + "\n").encode("utf-8", "replace")
|
|
151
367
|
|
|
152
368
|
|
|
369
|
+
_EXTENSIONS = ('.docx', '.pptx', '.xlsx', '.pdf')
|
|
370
|
+
|
|
371
|
+
|
|
153
372
|
def is_extracted(path):
|
|
154
373
|
"""Whether the file type goes through a text extractor before indexing.
|
|
155
374
|
|
|
156
|
-
Parameters: path (any file name or path). Returns: True for `.docx` and `.pdf`, case-insensitive
|
|
157
|
-
|
|
375
|
+
Parameters: path (any file name or path). Returns: True for `.docx`, `.pptx`, `.xlsx` and `.pdf`, case-insensitive;
|
|
376
|
+
macro and binary formats (`.xlsm`, `.xlsb`, `.xls`, `.ppt`, `.pptm`) are not extracted."""
|
|
377
|
+
return str(path).lower().endswith(_EXTENSIONS)
|
|
158
378
|
|
|
159
379
|
|
|
160
380
|
def size_limit(path):
|
|
@@ -169,7 +389,14 @@ def extract(path, data):
|
|
|
169
389
|
|
|
170
390
|
Parameters: path (decides the type, see is_extracted), data (file bytes). Returns: UTF-8 text bytes. Raises:
|
|
171
391
|
DocumentError with a readable reason."""
|
|
172
|
-
|
|
392
|
+
name = str(path).lower()
|
|
393
|
+
if name.endswith('.pdf'):
|
|
394
|
+
return extract_pdf(data)
|
|
395
|
+
if name.endswith('.pptx'):
|
|
396
|
+
return extract_pptx(data)
|
|
397
|
+
if name.endswith('.xlsx'):
|
|
398
|
+
return extract_xlsx(data)
|
|
399
|
+
return extract_docx(data)
|
|
173
400
|
|
|
174
401
|
|
|
175
402
|
def location_kind(path):
|
package/freshness.py
CHANGED
|
@@ -73,11 +73,13 @@ def npm_registry_evidence(package):
|
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
NPM_NAME_RE = re.compile(r"(?:@[a-z0-9._-]+/)?[a-z0-9._-]+")
|
|
76
|
+
NAME_RES = {"npm": NPM_NAME_RE, "PyPI": re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*"),
|
|
77
|
+
"Go": re.compile(r"[A-Za-z0-9][A-Za-z0-9._~/-]*")}
|
|
76
78
|
|
|
77
79
|
|
|
78
|
-
def _osv_query(package, package_version):
|
|
79
|
-
"""One OSV query object: the package
|
|
80
|
-
return {"package": {"name": package, "ecosystem":
|
|
80
|
+
def _osv_query(ecosystem, package, package_version):
|
|
81
|
+
"""One OSV query object: the package in `ecosystem` and the version asked about."""
|
|
82
|
+
return {"package": {"name": package, "ecosystem": ecosystem}, "version": package_version}
|
|
81
83
|
|
|
82
84
|
|
|
83
85
|
def _osv_request(endpoint, payload):
|
|
@@ -107,7 +109,7 @@ def npm_advisories(package, package_version):
|
|
|
107
109
|
"""IDs conhecidos pela OSV para a versão; None significa checagem indisponível."""
|
|
108
110
|
if not package or not package_version or not NPM_NAME_RE.fullmatch(package):
|
|
109
111
|
return None
|
|
110
|
-
request = _osv_request("query", _osv_query(package, package_version))
|
|
112
|
+
request = _osv_request("query", _osv_query("npm", package, package_version))
|
|
111
113
|
try:
|
|
112
114
|
with urllib.request.urlopen(request, timeout=7) as response:
|
|
113
115
|
return _osv_ids(json.load(response))
|
|
@@ -115,17 +117,21 @@ def npm_advisories(package, package_version):
|
|
|
115
117
|
return None
|
|
116
118
|
|
|
117
119
|
|
|
118
|
-
def
|
|
120
|
+
def advisories_batch(ecosystem, packages, timeout):
|
|
119
121
|
"""Advisory IDs for each (package, version) of `packages` from one OSV `querybatch` call.
|
|
120
122
|
|
|
121
|
-
Parameters: packages (list of (name, version)); timeout (float) seconds for
|
|
123
|
+
Parameters: ecosystem (str) "npm", "PyPI" or "Go"; packages (list of (name, version)); timeout (float) seconds for
|
|
124
|
+
the request.
|
|
122
125
|
Returns: list in the same order, each the sorted ID list or None when that package's answer is incomplete
|
|
123
126
|
(next_page_token) or malformed, so it is unverified and not clean.
|
|
124
|
-
Raises:
|
|
127
|
+
Raises: ValueError for an unknown ecosystem or an invalid name or version; RuntimeError when the service cannot be
|
|
128
|
+
reached, answers badly or answers a different number of results.
|
|
125
129
|
"""
|
|
126
|
-
if
|
|
127
|
-
raise ValueError(f"
|
|
128
|
-
|
|
130
|
+
if ecosystem not in NAME_RES:
|
|
131
|
+
raise ValueError(f"Unknown OSV ecosystem: {ecosystem!r}")
|
|
132
|
+
if any(not NAME_RES[ecosystem].fullmatch(name) or not package_version for name, package_version in packages):
|
|
133
|
+
raise ValueError(f"Invalid {ecosystem} package query: {packages!r}")
|
|
134
|
+
request = _osv_request("querybatch", {"queries": [_osv_query(ecosystem, name, package_version)
|
|
129
135
|
for name, package_version in packages]})
|
|
130
136
|
try:
|
|
131
137
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
package/hook_decision.py
CHANGED
|
@@ -230,20 +230,21 @@ def _edit_review(payload, doc_mode, duplicate_mode, dependency_mode):
|
|
|
230
230
|
"""Checks of an edit before it runs. Docstrings follow doc_mode, in code files of registered projects only (tests,
|
|
231
231
|
docs and files outside a project are left out, as in the docs coverage): missing ones deny the edit in require mode and become a reminder in remind mode; a
|
|
232
232
|
documented function whose signature changed always gets a reminder. Copies of indexed functions follow
|
|
233
|
-
duplicate_mode and only add context, never deny. An edit of a package.json follows dependency_mode: advisories
|
|
233
|
+
duplicate_mode and only add context, never deny. An edit of a package.json, requirements*.txt or go.mod follows dependency_mode: advisories
|
|
234
234
|
of the added or changed dependencies are added as context, never a denial."""
|
|
235
235
|
with code_graph.pending_scope():
|
|
236
236
|
return _edit_review_scoped(payload, doc_mode, duplicate_mode, dependency_mode)
|
|
237
237
|
|
|
238
238
|
|
|
239
|
-
def _dependency_part(before, after):
|
|
240
|
-
"""The dependency notice of one
|
|
239
|
+
def _dependency_part(before, after, ecosystem):
|
|
240
|
+
"""The dependency notice of one manifest edit, or the "dependency check skipped" line when it cannot run.
|
|
241
241
|
|
|
242
|
-
Parameters: before (str or None) text before the edit; after (str) text after it
|
|
242
|
+
Parameters: before (str or None) text before the edit; after (str) text after it; ecosystem (str) from
|
|
243
|
+
`dependency_notice.ecosystem_of`.
|
|
243
244
|
Returns: str, empty when there is nothing to say.
|
|
244
245
|
"""
|
|
245
246
|
try:
|
|
246
|
-
return dependency_notice.notice(before, after)
|
|
247
|
+
return dependency_notice.notice(before, after, ecosystem)
|
|
247
248
|
except dependency_notice.DependencyCheckError as exc:
|
|
248
249
|
return f"{dependency_notice.PREFIX}dependency check skipped: {exc}"
|
|
249
250
|
|
|
@@ -252,8 +253,9 @@ def _edit_review_scoped(payload, doc_mode, duplicate_mode, dependency_mode):
|
|
|
252
253
|
"""`_edit_review` inside one `code_graph.pending_scope`, so the pending-changes scan runs once per review."""
|
|
253
254
|
doc_findings, duplicate_findings, failures, dependency_parts = [], [], [], []
|
|
254
255
|
for path, before, after in edit_preview.preview(payload.get("tool_name"), payload.get("tool_input"), payload.get("cwd")):
|
|
255
|
-
|
|
256
|
-
|
|
256
|
+
ecosystem = dependency_notice.ecosystem_of(path)
|
|
257
|
+
if dependency_mode != "off" and ecosystem:
|
|
258
|
+
dependency_parts += [part for part in (_dependency_part(before, after, ecosystem),) if part]
|
|
257
259
|
continue
|
|
258
260
|
if not doc_check.supported(path):
|
|
259
261
|
continue
|
package/hooks/session_audit.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""SessionStart hook of Claude Code (opt-in, from the setup screen): forwards the payload to the Smart Tool daemon
|
|
2
|
+
"""SessionStart hook of Claude Code and Codex (opt-in, from the setup screen): forwards the payload to the Smart Tool daemon
|
|
3
3
|
(POST /hooks/session-audit) and prints its answer, the stored setup audit notice of the session folder as additional
|
|
4
4
|
context. Never starts the daemon or an audit: daemon down or any error means empty output, so a session start is
|
|
5
5
|
never delayed or blocked.
|
package/index_profile.py
CHANGED
|
@@ -11,7 +11,7 @@ PROFILE_VERSION = 2
|
|
|
11
11
|
KINDS = ("code", "test", "doc")
|
|
12
12
|
RETRY_AFTER_S = 3600
|
|
13
13
|
MAX_DIRS = 90
|
|
14
|
-
PROSE = {"md", "txt", "docx", "pdf", "rst", "adoc"}
|
|
14
|
+
PROSE = {"md", "txt", "docx", "pptx", "xlsx", "pdf", "rst", "adoc"}
|
|
15
15
|
# Data and markup inside a documentation, design or data-dump folder are reports and mockups, not code (GitHub Linguist
|
|
16
16
|
# treats docs/ as documentation; a design/ HTML or a probe's JSON result pushed hook_decision.py out of the code block).
|
|
17
17
|
DOC_DATA = {"html", "htm", "json", "csv", "xml", "yaml", "yml", "svg"}
|
package/index_scope.py
CHANGED
|
@@ -40,10 +40,13 @@ _PROJECT_TYPE_EXCLUDES = (
|
|
|
40
40
|
(("Podfile",), ("Pods",)),
|
|
41
41
|
)
|
|
42
42
|
|
|
43
|
+
# Files a tool writes while it works and replaces or deletes right after (atomic-write temp, backup, editor swap).
|
|
44
|
+
TRANSIENT_FILES = {"*.tmp", "*.bak", "*.swp", "*~"}
|
|
45
|
+
|
|
43
46
|
# Mesmo critério do `_ALWAYS_EXCLUDE`, para arquivo: o significado não varia por
|
|
44
47
|
# projeto. Lockfile é grafo de dependência resolvido por ferramenta — nunca é fonte de
|
|
45
48
|
# resposta numa busca semântica, e é grande o bastante pra dominar o índice.
|
|
46
|
-
_ALWAYS_EXCLUDE_FILES = {
|
|
49
|
+
_ALWAYS_EXCLUDE_FILES = TRANSIENT_FILES | {
|
|
47
50
|
"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json",
|
|
48
51
|
"poetry.lock", "pdm.lock", "uv.lock", "Pipfile.lock", "Cargo.lock",
|
|
49
52
|
"composer.lock", "go.sum", "Gemfile.lock", "packages.lock.json",
|
|
@@ -110,6 +113,11 @@ def normalize_include(include):
|
|
|
110
113
|
return bases, dropped
|
|
111
114
|
|
|
112
115
|
|
|
116
|
+
def is_transient(name):
|
|
117
|
+
"""Whether a file name is a temporary, backup or editor-swap file (`*.tmp`, `*.bak`, `*.swp`, `*~`)."""
|
|
118
|
+
return any(fnmatch.fnmatch(name.casefold(), pattern) for pattern in TRANSIENT_FILES)
|
|
119
|
+
|
|
120
|
+
|
|
113
121
|
def always_excluded(name):
|
|
114
122
|
"""Directory name in _ALWAYS_EXCLUDE, entries may be globs (e.g. *.egg-info)."""
|
|
115
123
|
folded = name.casefold()
|
package/indexer.py
CHANGED
|
@@ -31,8 +31,10 @@ CHUNK_MAX_CHARS = 8000
|
|
|
31
31
|
CHUNK_OVERLAP_LINES = 12
|
|
32
32
|
CHUNKER_VERSION = 3
|
|
33
33
|
SCHEMA_VERSION = 2
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
# An embeddings request holds up to 128 items and 120,000 characters (about 30,000 tokens): far under the 2048 items and
|
|
35
|
+
# 300,000 tokens per request that OpenAI and Azure accept (docs/research/2026-10-27-excesso-de-chamadas-embed-rerank.md).
|
|
36
|
+
REINDEX_BATCH_MAX_CHARS = 120_000
|
|
37
|
+
REINDEX_BATCH_MAX_ITEMS = 128
|
|
36
38
|
# Embedding requests in flight per flush. Measured 2026-10-03 (docs/spikes/embed_parallel_spike.py, 30 batches,
|
|
37
39
|
# 3 interleaved rounds): 1 worker 33-50 s, 2 workers 19-27 s, 4 workers 11-20 s (~2.8x), no 429 from the gateway.
|
|
38
40
|
EMBED_WORKERS = 4
|
package/model_breaker.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Protection of the model gateway, per model id: a circuit that opens after consecutive 429/503 answers and a cap on
|
|
2
|
+
the requests in flight.
|
|
3
|
+
|
|
4
|
+
Pattern: a circuit breaker (closed, open for a pause, then one trial call decides). Retrying a gateway that answers
|
|
5
|
+
busy only adds load (AWS Bedrock throughput guide: honor Retry-After, back off with jitter); once it keeps answering
|
|
6
|
+
busy the caller stops asking for a while and serves what it can without the model.
|
|
7
|
+
"""
|
|
8
|
+
import contextlib
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
|
|
13
|
+
FAILURES_TO_OPEN = 3
|
|
14
|
+
PAUSE_S = 30
|
|
15
|
+
MAX_IN_FLIGHT = 6
|
|
16
|
+
_BUSY_CODES = (429, 503)
|
|
17
|
+
_lock = threading.Lock()
|
|
18
|
+
_failures = {}
|
|
19
|
+
_open_until = {}
|
|
20
|
+
_slots = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CircuitOpen(RuntimeError):
|
|
24
|
+
"""The model is paused after repeated busy answers; no request was sent."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def guard(model):
|
|
28
|
+
"""Raises CircuitOpen while `model` is paused. Nothing happens for a call without a model id."""
|
|
29
|
+
if not model:
|
|
30
|
+
return
|
|
31
|
+
with _lock:
|
|
32
|
+
left = _open_until.get(model, 0) - time.monotonic()
|
|
33
|
+
if left > 0:
|
|
34
|
+
raise CircuitOpen(f"Model {model} is paused for {int(left) + 1} s after {FAILURES_TO_OPEN} consecutive "
|
|
35
|
+
"HTTP 429/503 answers from the gateway; no request was sent.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def note(model, failure=None):
|
|
39
|
+
"""Records the outcome of a request: a success closes the circuit, a 429/503 counts toward opening it, any other
|
|
40
|
+
failure changes nothing. After the pause the first failure opens it again."""
|
|
41
|
+
if not model:
|
|
42
|
+
return
|
|
43
|
+
busy = isinstance(failure, urllib.error.HTTPError) and failure.code in _BUSY_CODES
|
|
44
|
+
with _lock:
|
|
45
|
+
if failure is None:
|
|
46
|
+
_failures[model] = 0
|
|
47
|
+
elif busy:
|
|
48
|
+
_failures[model] = _failures.get(model, 0) + 1
|
|
49
|
+
if _failures[model] >= FAILURES_TO_OPEN:
|
|
50
|
+
_open_until[model] = time.monotonic() + PAUSE_S
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@contextlib.contextmanager
|
|
54
|
+
def slot(model):
|
|
55
|
+
"""Holds one of the `MAX_IN_FLIGHT` places of `model` for the duration of a request."""
|
|
56
|
+
if not model:
|
|
57
|
+
yield
|
|
58
|
+
return
|
|
59
|
+
with _lock:
|
|
60
|
+
semaphore = _slots.setdefault(model, threading.BoundedSemaphore(MAX_IN_FLIGHT))
|
|
61
|
+
with semaphore:
|
|
62
|
+
yield
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def reset():
|
|
66
|
+
"""Forgets every circuit and cap (tests)."""
|
|
67
|
+
with _lock:
|
|
68
|
+
_failures.clear()
|
|
69
|
+
_open_until.clear()
|
|
70
|
+
_slots.clear()
|
package/model_client.py
CHANGED
|
@@ -9,6 +9,7 @@ import usage_meter
|
|
|
9
9
|
|
|
10
10
|
import config
|
|
11
11
|
import gateway
|
|
12
|
+
import model_breaker
|
|
12
13
|
import model_health
|
|
13
14
|
|
|
14
15
|
OFFLINE = contextvars.ContextVar("model_offline", default=None)
|
|
@@ -26,18 +27,24 @@ def get_token():
|
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
def fetch(path, token, method="GET", body=None, timeout=15):
|
|
30
|
+
"""One gateway request; returns the parsed JSON. A call that names a model first passes that model's circuit and
|
|
31
|
+
request cap (model_breaker, CircuitOpen when paused), and every attempt is metered. HTTP errors propagate."""
|
|
29
32
|
if OFFLINE.get():
|
|
30
33
|
raise RuntimeError(OFFLINE.get())
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
model=body.get("model") if isinstance(body,dict) else None
|
|
35
|
+
model_breaker.guard(model)
|
|
36
|
+
with model_breaker.slot(model):
|
|
37
|
+
started=time.monotonic();result=None;headers=None;failure=None
|
|
38
|
+
try:
|
|
39
|
+
result,headers=_fetch(path,token,method,body,timeout)
|
|
40
|
+
return result
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
failure=exc
|
|
43
|
+
raise
|
|
44
|
+
finally:
|
|
45
|
+
model_breaker.note(model,failure)
|
|
46
|
+
usage_meter.record(path,body,result,headers,failure,time.monotonic()-started)
|
|
47
|
+
model_health.record(path,body,failure)
|
|
41
48
|
|
|
42
49
|
|
|
43
50
|
def _fetch(path, _token, method="GET", body=None, timeout=15):
|