@allansantos-dev/smart-tool 0.9.39 → 0.9.41
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 +46 -0
- package/README.md +12 -5
- package/config.py +11 -0
- package/dependency_notice.py +280 -0
- package/document_text.py +114 -3
- package/freshness.py +64 -19
- package/hook_decision.py +41 -11
- package/index_profile.py +1 -1
- package/index_scope.py +9 -1
- package/indexer.py +10 -8
- 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 +16 -3
- package/setup_audit.py +46 -4
- package/setup_ui.py +37 -10
- package/smart_tool_daemon.py +130 -68
- package/tray.py +7 -1
- package/usage_meter.py +13 -0
- package/version.py +1 -1
- package/web_fetch.py +13 -41
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.41 - beta
|
|
4
|
+
|
|
5
|
+
- Far fewer calls to the embedding and rerank models. Measured on a real machine (24 h): 8303 embedding and 1473 rerank
|
|
6
|
+
calls, 0.3% of them failing; the volume came from four places, all cut: (1) an indexing job no longer embeds a fixed
|
|
7
|
+
text to learn the vector size when the index already stores it for the same model (1300 calls a day); (2) embedding
|
|
8
|
+
batches carry up to 128 chunks and 120,000 characters instead of 32,000 characters (3.8 chunks per call before); a
|
|
9
|
+
request the gateway refuses for its size is split in two halves once; (3) one rerank call per search instead of one
|
|
10
|
+
per block (code, test, doc); the blocks keep the order the separate calls gave; (4) a project in `eager` mode whose
|
|
11
|
+
files change all the time waits longer between jobs (60, 120, 240, 480 up to 900 s, back to 60 s after a quiet
|
|
12
|
+
period), and `*.tmp`, `*.bak`, `*.swp` and `*~` files no longer mark a project as changed or enter the index.
|
|
13
|
+
Measurement before and after in `docs/research/data/call-volume-*.json`, findings and sources in
|
|
14
|
+
`docs/research/2026-10-27-excesso-de-chamadas-embed-rerank.md`. Tests in `tests/test_call_volume.py`.
|
|
15
|
+
- A model that answers 429 or 503 three times in a row opens a circuit for 30 s: calls to it fail at once and the search
|
|
16
|
+
answers from the lexical index with the usual warning, instead of repeating requests. At most 6 requests are in
|
|
17
|
+
flight per model. Rerank and the model check wait the gateway's `Retry-After` (or a jittered exponential delay) before
|
|
18
|
+
asking again, like embeddings.
|
|
19
|
+
- The setup Models page groups the failures by type (429, 503, other 5xx, timeout, connection, authentication, other):
|
|
20
|
+
one row per type and role with the count, first and last time, one sample message, and the latest 20 lines on
|
|
21
|
+
expand. The summary shows the calls per purpose (indexing, query, rerank, model check). `dependency_mode` has its own
|
|
22
|
+
card in the setup rules.
|
|
23
|
+
- The dependency notice also reads `requirements*.txt` (PyPI, `==` pins and the lower bound of `>=` and `~=`) and `go.mod`
|
|
24
|
+
(Go, `require` lines with a plain `vX.Y.Z`). `web_fetch` reads PDFs with the same page and character caps as the
|
|
25
|
+
indexer (`document_text`).
|
|
26
|
+
|
|
27
|
+
## 0.9.40 - beta
|
|
28
|
+
|
|
29
|
+
- PDFs of the project are searchable. `smart_search` indexes the text layer of a `.pdf` inside the project scope and
|
|
30
|
+
returns the page (`[page N]`) with the passage. The extractor is the one `web_fetch` already used, now in
|
|
31
|
+
`document_text`: `pypdf` in an isolated subprocess killed after 20 s, at most 20 MiB, 300 pages and 1,000,000
|
|
32
|
+
characters per file. A scanned PDF (no text layer), a password-protected one, a corrupt one or one that overruns the
|
|
33
|
+
deadline is skipped with the reason in the index stats and the rest of the project is indexed as usual. The secret,
|
|
34
|
+
minified and long-line guards apply to the extracted text. `document_text.VERSION` is 2: every `.docx` already indexed
|
|
35
|
+
is read once more. Tests in `tests/test_pdf_index.py`; measurement before and after in
|
|
36
|
+
`docs/research/data/pdf-probe-*.json`.
|
|
37
|
+
- Editing `package.json` warns about known-vulnerable dependencies. Added or changed entries of `dependencies`,
|
|
38
|
+
`devDependencies`, `peerDependencies` and `optionalDependencies` (at most 5 per edit) are checked against OSV with one
|
|
39
|
+
`querybatch` call (2.5 s timeout, answers cached on disk for 6 h); the edit reminder lists the advisory IDs per
|
|
40
|
+
`package@version`, or says `dependency check skipped: <reason>` when OSV cannot be reached, and the edit goes on.
|
|
41
|
+
Only package names and versions leave the machine. `dependency_mode` in `config.json` (`warn` by default, `off`)
|
|
42
|
+
turns it off. Tests in `tests/test_dependency_notice.py`; measurement in `docs/research/data/dependency-probe-*.json`.
|
|
43
|
+
- A scheduled setup audit that finds new errors or warnings raises one Windows toast per project with an "Open the
|
|
44
|
+
audit" button; the first audit, manual runs and info findings stay silent (`tests/test_audit_toast.py`).
|
|
45
|
+
- `smart_scout` answers a question about intent (why, reason, decided) with up to three decision notes from the project
|
|
46
|
+
notes next to the code lines (`tests/test_scout.py`).
|
|
47
|
+
- The edit reminder gives the command that runs the tests calling the edited function (`tests/test_edit_review_cache.py`).
|
|
48
|
+
|
|
3
49
|
## 0.9.39 - beta
|
|
4
50
|
|
|
5
51
|
- Two-letter technical terms (`ui`, `db`, `id`) count again in the shared `focus_terms`, used by `smart_search
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharpe
|
|
|
5
5
|
| Tool | What it does |
|
|
6
6
|
|---|---|
|
|
7
7
|
| `smart_search` / `smart_search_result` | Semantic + lexical search over an indexed project, results split into code, tests and docs, with the likeliest functions per chunk |
|
|
8
|
-
| `smart_scout` | Answers a question about the code with the lines that answer it: reads the candidate files inside Smart Tool and returns `path:lines` with the literal text, so the files never enter the agent's context; with a `scout_model`, a small model also writes a short answer and keeps only the lines that support it |
|
|
8
|
+
| `smart_scout` | Answers a question about the code with the lines that answer it: reads the candidate files inside Smart Tool and returns `path:lines` with the literal text, so the files never enter the agent's context; with a `scout_model`, a small model also writes a short answer and keeps only the lines that support it; a question about why something is as it is (why, reason, decided) also lists up to three decision notes from the notes the project keeps about its intent, as ids with one excerpt each |
|
|
9
9
|
| `web_search` / `web_search_result` | Web search across several sources in parallel, in the query language and in English, with a cache shared across sessions; `depth="research"` runs a multi-round research loop |
|
|
10
10
|
| `web_fetch` | Reads a known URL and returns only the answer to your prompt, written by a small model; pages are cached for 24 h. Pages over 60,000 characters are not cut: the model reads the parts closest to the prompt (needs an `embedding_model`) |
|
|
11
11
|
| `project_manage` | Registers and indexes projects, shows coverage, code maps, duplicated functions and index storage; `graph` with `symbol` tells what changing a function touches: callers, calls, the tests that reach it and the files that import it |
|
|
@@ -16,7 +16,7 @@ registered as the `playwright` server where this machine lets a browser be drive
|
|
|
16
16
|
An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
|
|
17
17
|
are cheaper, or just tells the agent what Smart Tool would do.
|
|
18
18
|
|
|
19
|
-
**Status:** 0.9.
|
|
19
|
+
**Status:** 0.9.41 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
|
|
20
20
|
|
|
21
21
|
## How it works
|
|
22
22
|
|
|
@@ -129,7 +129,7 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
129
129
|
When a new version recommends other models than the ones you use, a Windows notification (**Apply now**, **Don't
|
|
130
130
|
show again**) and the **New recommended models** card on this page offer them: nothing changes until you apply, the
|
|
131
131
|
models you had are kept, and **Switch back to my previous models** undoes it. The **Model failures (last 24 h)** table
|
|
132
|
-
|
|
132
|
+
groups the failed gateway calls by type (429, 503, other 5xx, timeout, connection, authentication) and role, with the count, the first and last time and one sample, and shows the calls per purpose; a model that fails half of its calls (5 or more) also raises a
|
|
133
133
|
notification and a tray alert. A failure inside a tool call also comes back as a warning line in the tool answer;
|
|
134
134
|
calls made by a hook or by background work (index scope and profile, block-pattern proposals) have no answer to
|
|
135
135
|
carry it, so they show only in the table, the log and the notification. The local embedder is not in that table or in
|
|
@@ -181,13 +181,18 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
181
181
|
`@override`) functions are exempt, as in pydocstyle, eslint-plugin-jsdoc and Checkstyle, and a JSDoc above a set of
|
|
182
182
|
TypeScript overloads documents the implementation:
|
|
183
183
|
- **Remind** (default): the edit runs and the agent is told which touched functions have no docstring, and which
|
|
184
|
-
documented ones changed their signature, with their callers
|
|
184
|
+
documented ones changed their signature, with their callers, the tests calling them and the command that runs those tests
|
|
185
|
+
(from the code graph, only when it is already cached: the hook never waits for an analysis).
|
|
185
186
|
- **Require**: an edit that leaves a touched function without a docstring is blocked until it adds one.
|
|
186
187
|
- **Off**: no checks.
|
|
187
188
|
|
|
188
189
|
A documented function edited only in its body is not mentioned. `project_manage` with `action=docs` lists the
|
|
189
190
|
public functions still without a docstring, for documenting a project that started without them.
|
|
190
191
|
|
|
192
|
+
**Dependency check on edit**: an edit of `package.json`, `requirements*.txt` or `go.mod` that adds or changes a dependency lists the OSV advisory IDs of up to 5 of them (exact version, or the lower bound of `^`, `~`, `~=` and `>=`), answered from a 6 h disk cache or one 2.5 s batch call that sends only package names and versions; when OSV cannot answer, the reminder says the check was skipped and the edit goes on. `dependency_mode` in `config.json`: `warn` (default) or `off`.
|
|
193
|
+
|
|
194
|
+
**PDFs** inside a project are indexed as text with `[page N]` markers (20 MiB, 300 pages and 20 s per file; a scanned, password-protected or corrupt PDF is skipped with the reason), so `smart_search` returns the page that holds a passage.
|
|
195
|
+
|
|
191
196
|
**Block patterns waiting for you** are shown in one approval panel: 8 per page, the ones that would have blocked
|
|
192
197
|
the most logged calls first, a filter over pattern, reason and example, and bulk accept or reject of the selected
|
|
193
198
|
rows; each row opens to its reason and example.
|
|
@@ -219,7 +224,9 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
219
224
|
two minutes after it starts and then every 30 minutes (MCP servers shared by several projects are contacted once
|
|
220
225
|
and their result reused for 6 hours; Run now always contacts them again), and the setup screen's **Setup audit**
|
|
221
226
|
panel shows each project's last report: findings by severity, what is new, what was resolved, the tokens each MCP
|
|
222
|
-
server's tools cost and what was not verified.
|
|
227
|
+
server's tools cost and what was not verified. When a scheduled run finds new errors or warnings, a Windows
|
|
228
|
+
notification from the tray names the folder and up to three of them, once per finding, with a button that opens the
|
|
229
|
+
panel; manual runs and info findings stay silent.
|
|
223
230
|
|
|
224
231
|
The agent hears about it without asking for it. `setup_audit` with `mode=last` returns the stored report for the
|
|
225
232
|
folder (or the nearest audited parent) without auditing again, and fails with what to do when none is stored. On
|
package/config.py
CHANGED
|
@@ -37,6 +37,7 @@ DEFAULT_CONFIG = {
|
|
|
37
37
|
"hook_mode": "redirect",
|
|
38
38
|
"doc_mode": "remind",
|
|
39
39
|
"duplicate_mode": "warn",
|
|
40
|
+
"dependency_mode": "warn",
|
|
40
41
|
"auto_update": True,
|
|
41
42
|
"pattern_proposals": "on",
|
|
42
43
|
"contact": "",
|
|
@@ -61,6 +62,9 @@ DOC_MODES = ("require", "remind", "off")
|
|
|
61
62
|
# Edit hook on functions that copy one already indexed (exact or near-identical body): warn = let the edit run and
|
|
62
63
|
# point to the existing function; off = say nothing. Never blocks: near matches are leads, not defects.
|
|
63
64
|
DUPLICATE_MODES = ("warn", "off")
|
|
65
|
+
# Edit hook on package.json, requirements*.txt and go.mod: warn = let the edit run and list the added or changed dependencies that have OSV advisories;
|
|
66
|
+
# off = say nothing and make no network call.
|
|
67
|
+
DEPENDENCY_MODES = ("warn", "off")
|
|
64
68
|
# Router model reviewing, in the background, searches the deterministic redirect rule let run: on = propose block
|
|
65
69
|
# patterns for the user to accept in the setup screen; off = no review.
|
|
66
70
|
PATTERN_PROPOSAL_MODES = ("on", "off")
|
|
@@ -85,6 +89,13 @@ def duplicate_mode(cfg=None):
|
|
|
85
89
|
return value
|
|
86
90
|
|
|
87
91
|
|
|
92
|
+
def dependency_mode(cfg=None):
|
|
93
|
+
value = (cfg if cfg is not None else load_config()).get("dependency_mode") or DEFAULT_CONFIG["dependency_mode"]
|
|
94
|
+
if value not in DEPENDENCY_MODES:
|
|
95
|
+
raise ValueError(f"Invalid dependency_mode ({value!r}) in {CONFIG_PATH}: use warn or off.")
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
|
|
88
99
|
def pattern_proposals(cfg=None):
|
|
89
100
|
"""on: the router model reviews searches the redirect rule let run and proposes block patterns for the user to
|
|
90
101
|
accept; off: no review."""
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Vulnerable-dependency notice for an edit of a dependency manifest, built before the edit runs.
|
|
2
|
+
|
|
3
|
+
Manifests: package.json (OSV ecosystem npm), requirements*.txt (PyPI: `==` pins and the lower bound of `>=` and `~=`)
|
|
4
|
+
and go.mod (Go: `require` lines with a plain vX.Y.Z). Compares the dependencies of the file before and after the edit,
|
|
5
|
+
asks OSV (one `querybatch` call, short timeout) about the added or changed ones only, and answers from a disk cache of
|
|
6
|
+
(ecosystem, package, version) -> advisory IDs for 6 hours. Only package names and versions leave the machine. The caller (`hook_decision`) turns a `DependencyCheckError` into
|
|
7
|
+
"dependency check skipped: <reason>" and lets the edit go on.
|
|
8
|
+
"""
|
|
9
|
+
import collections
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
import atomic_io
|
|
16
|
+
import freshness
|
|
17
|
+
import paths
|
|
18
|
+
|
|
19
|
+
CACHE_PATH = os.path.join(paths.DATA_DIR, "dependency_advisories.json")
|
|
20
|
+
CACHE_TTL_S = 6 * 3600
|
|
21
|
+
OSV_TIMEOUT_S = 2.5
|
|
22
|
+
MAX_PACKAGES = 5
|
|
23
|
+
MAX_IDS_SHOWN = 5
|
|
24
|
+
TABLES = ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies")
|
|
25
|
+
PREFIX = "Smart Tool dependencies: "
|
|
26
|
+
_SPECIFIER_RE = re.compile(r"(\^|~|>=|>|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?"
|
|
27
|
+
r"((?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)")
|
|
28
|
+
_REQUIREMENTS_FILE_RE = re.compile(r"requirements[A-Za-z0-9._-]*\.txt")
|
|
29
|
+
_REQUIREMENT_RE = re.compile(r"([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*([^;]*)")
|
|
30
|
+
_PIP_VERSION_RE = re.compile(r"[0-9][0-9A-Za-z.+!_-]*")
|
|
31
|
+
_GO_VERSION_RE = re.compile(r"v\d+\.\d+\.\d+")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DependencyCheckError(RuntimeError):
|
|
35
|
+
"""The check could not run (OSV unreachable or answering badly, unreadable cache, current file not JSON)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _npm_pairs(text):
|
|
39
|
+
"""The (name, specifier) pairs of every dependency table in a package.json text.
|
|
40
|
+
|
|
41
|
+
Parameters: text (str) file content.
|
|
42
|
+
Returns: dict (name, specifier) -> None, in file order; None when the text is not a JSON object.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(text)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return None
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
return None
|
|
50
|
+
pairs = {}
|
|
51
|
+
for table in TABLES:
|
|
52
|
+
entries = data.get(table)
|
|
53
|
+
if isinstance(entries, dict):
|
|
54
|
+
pairs.update({(name, spec): None for name, spec in entries.items() if isinstance(spec, str)})
|
|
55
|
+
return pairs
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _requirements_pairs(text):
|
|
59
|
+
"""The (name, specifier) pairs of a requirements.txt text; options, includes, editable and URL lines are skipped.
|
|
60
|
+
|
|
61
|
+
Parameters: text (str) file content.
|
|
62
|
+
Returns: dict (name, specifier) -> None in file order; the specifier is the version clauses without spaces ("" when
|
|
63
|
+
the line only names the package).
|
|
64
|
+
"""
|
|
65
|
+
pairs = {}
|
|
66
|
+
for raw in text.splitlines():
|
|
67
|
+
line = raw.split(" #")[0].split(";")[0].strip().rstrip("\\").strip()
|
|
68
|
+
if not line or line.startswith(("#", "-")):
|
|
69
|
+
continue
|
|
70
|
+
line = " ".join(word for word in line.split() if not word.startswith("--"))
|
|
71
|
+
match = _REQUIREMENT_RE.fullmatch(line)
|
|
72
|
+
if not match:
|
|
73
|
+
continue
|
|
74
|
+
specifier = "".join(match.group(2).split())
|
|
75
|
+
if specifier and specifier[0] not in "=<>!~":
|
|
76
|
+
continue
|
|
77
|
+
pairs[(match.group(1), specifier)] = None
|
|
78
|
+
return pairs
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _go_pairs(text):
|
|
82
|
+
"""The (module, version) pairs of the `require` lines of a go.mod text, single-line and block; replace, exclude and
|
|
83
|
+
the other directives are skipped.
|
|
84
|
+
|
|
85
|
+
Parameters: text (str) file content.
|
|
86
|
+
Returns: dict (module, version) -> None in file order.
|
|
87
|
+
"""
|
|
88
|
+
pairs, block = {}, None
|
|
89
|
+
for raw in text.splitlines():
|
|
90
|
+
tokens = raw.split("//")[0].split()
|
|
91
|
+
if not tokens:
|
|
92
|
+
continue
|
|
93
|
+
if block is None:
|
|
94
|
+
if tokens[0] == "require" and len(tokens) == 3:
|
|
95
|
+
pairs[(tokens[1], tokens[2])] = None
|
|
96
|
+
elif tokens[-1] == "(":
|
|
97
|
+
block = tokens[0]
|
|
98
|
+
elif tokens == [")"]:
|
|
99
|
+
block = None
|
|
100
|
+
elif block == "require" and len(tokens) == 2:
|
|
101
|
+
pairs[(tokens[0], tokens[1])] = None
|
|
102
|
+
return pairs
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def ecosystem_of(path):
|
|
106
|
+
"""The OSV ecosystem of a manifest file name.
|
|
107
|
+
|
|
108
|
+
Parameters: path (str) file path.
|
|
109
|
+
Returns: "npm" for package.json, "PyPI" for requirements*.txt, "Go" for go.mod; None for any other file.
|
|
110
|
+
"""
|
|
111
|
+
name = os.path.basename(path)
|
|
112
|
+
if name == "package.json":
|
|
113
|
+
return "npm"
|
|
114
|
+
if name == "go.mod":
|
|
115
|
+
return "Go"
|
|
116
|
+
return "PyPI" if _REQUIREMENTS_FILE_RE.fullmatch(name) else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def changed_dependencies(before, after, ecosystem="npm"):
|
|
120
|
+
"""Dependencies added or whose specifier changed between two manifest texts.
|
|
121
|
+
|
|
122
|
+
Parameters: before (str or None) text before the edit, None when the file did not exist; after (str) text after;
|
|
123
|
+
ecosystem (str) one of ECOSYSTEMS.
|
|
124
|
+
Returns: list of (name, specifier); empty when `after` is a package.json that is not valid JSON (mid-edit).
|
|
125
|
+
Raises: DependencyCheckError when `before` exists but is a package.json that is not valid JSON.
|
|
126
|
+
"""
|
|
127
|
+
parse = _ECOSYSTEMS[ecosystem].parse
|
|
128
|
+
now = parse(after)
|
|
129
|
+
if now is None:
|
|
130
|
+
return []
|
|
131
|
+
if before is None or not before.strip():
|
|
132
|
+
old = {}
|
|
133
|
+
else:
|
|
134
|
+
old = parse(before)
|
|
135
|
+
if old is None:
|
|
136
|
+
raise DependencyCheckError("current package.json is not valid JSON")
|
|
137
|
+
return [pair for pair in now if pair not in old]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _npm_version(specifier):
|
|
141
|
+
"""The npm case of `queried_version`: the exact version or the lower bound of a ^, ~, >= or > range; None for tags,
|
|
142
|
+
`*`, URLs, file:, git, workspace:, npm: aliases, x-ranges and compound ranges."""
|
|
143
|
+
match = _SPECIFIER_RE.fullmatch(specifier.strip())
|
|
144
|
+
if not match:
|
|
145
|
+
return None
|
|
146
|
+
operator, major, minor, patch, tail = match.groups()
|
|
147
|
+
parts = [major, minor, patch]
|
|
148
|
+
if operator in ("^", "~", ">=", ">"):
|
|
149
|
+
return ".".join(part or "0" for part in parts) + (tail or "")
|
|
150
|
+
if None in parts:
|
|
151
|
+
return None
|
|
152
|
+
return ".".join(parts) + (tail or "")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _pip_version(specifier):
|
|
156
|
+
"""The PyPI case of `queried_version`: the `==` version, else the first `>=` or `~=` lower bound; None for no
|
|
157
|
+
specifier, `===`, wildcards, `<`, `>`, `!=` alone and anything that is not a version."""
|
|
158
|
+
clauses = specifier.split(",")
|
|
159
|
+
for operators in (("==",), (">=", "~=")):
|
|
160
|
+
for clause in clauses:
|
|
161
|
+
for operator in operators:
|
|
162
|
+
if clause.startswith(operator) and _PIP_VERSION_RE.fullmatch(clause[len(operator):]):
|
|
163
|
+
return clause[len(operator):]
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _go_version(specifier):
|
|
168
|
+
"""The Go case of `queried_version`: a plain vX.Y.Z; None for pseudo-versions, pre-releases and +incompatible."""
|
|
169
|
+
return specifier if _GO_VERSION_RE.fullmatch(specifier) else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def queried_version(specifier, ecosystem="npm"):
|
|
173
|
+
"""The version to ask OSV about for a specifier.
|
|
174
|
+
|
|
175
|
+
Parameters: specifier (str) the value in the dependency table; ecosystem (str) one of ECOSYSTEMS.
|
|
176
|
+
Returns: str, the exact version or the lower bound of the range; None when the specifier cannot be reduced safely.
|
|
177
|
+
"""
|
|
178
|
+
return _ECOSYSTEMS[ecosystem].version(specifier)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
_Ecosystem = collections.namedtuple("_Ecosystem", "parse version label exact")
|
|
182
|
+
_ECOSYSTEMS = {
|
|
183
|
+
"npm": _Ecosystem(_npm_pairs, _npm_version, lambda name, spec: f"{name}@{spec}", lambda spec, version: spec == version),
|
|
184
|
+
"PyPI": _Ecosystem(_requirements_pairs, _pip_version, lambda name, spec: f"{name}{spec}",
|
|
185
|
+
lambda spec, version: spec == "==" + version),
|
|
186
|
+
"Go": _Ecosystem(_go_pairs, _go_version, lambda name, spec: f"{name}@{spec}", lambda spec, version: spec == version),
|
|
187
|
+
}
|
|
188
|
+
ECOSYSTEMS = tuple(_ECOSYSTEMS)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _load_cache():
|
|
192
|
+
"""The cache as {"name@version": {"ids": [...], "at": epoch}}; empty when the file does not exist.
|
|
193
|
+
|
|
194
|
+
Raises: DependencyCheckError when the file exists and is unreadable, not JSON or not an object (never reset).
|
|
195
|
+
"""
|
|
196
|
+
if not os.path.exists(CACHE_PATH):
|
|
197
|
+
return {}
|
|
198
|
+
try:
|
|
199
|
+
with open(CACHE_PATH, encoding="utf-8") as stream:
|
|
200
|
+
data = json.load(stream)
|
|
201
|
+
except (OSError, ValueError) as exc:
|
|
202
|
+
raise DependencyCheckError(f"advisory cache {CACHE_PATH} is unreadable: {exc}") from exc
|
|
203
|
+
if not isinstance(data, dict):
|
|
204
|
+
raise DependencyCheckError(f"advisory cache {CACHE_PATH} is not a JSON object")
|
|
205
|
+
return data
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _fresh_ids(entry, now):
|
|
209
|
+
"""The cached IDs of an entry, or None when it is missing, malformed or older than CACHE_TTL_S."""
|
|
210
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("ids"), list) or not isinstance(entry.get("at"), (int, float)):
|
|
211
|
+
return None
|
|
212
|
+
return entry["ids"] if now - entry["at"] < CACHE_TTL_S else None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _advisories(ecosystem, queries):
|
|
216
|
+
"""Advisory IDs for (name, version) pairs, from the cache and one OSV batch for the rest.
|
|
217
|
+
|
|
218
|
+
Parameters: ecosystem (str) one of ECOSYSTEMS; queries (list of (name, version)).
|
|
219
|
+
Returns: tuple (list of IDs or None per query in order, str cache-write problem or "").
|
|
220
|
+
Raises: DependencyCheckError when the cache is corrupt or OSV cannot answer.
|
|
221
|
+
"""
|
|
222
|
+
cache, now = _load_cache(), time.time()
|
|
223
|
+
key = lambda query: f"{ecosystem}:{query[0]}@{query[1]}" # noqa: E731
|
|
224
|
+
found = {query: _fresh_ids(cache.get(key(query)), now) for query in queries}
|
|
225
|
+
missing = [query for query in queries if found[query] is None]
|
|
226
|
+
if not missing:
|
|
227
|
+
return [found[query] for query in queries], ""
|
|
228
|
+
try:
|
|
229
|
+
answered = freshness.advisories_batch(ecosystem, missing, OSV_TIMEOUT_S)
|
|
230
|
+
except RuntimeError as exc:
|
|
231
|
+
raise DependencyCheckError(str(exc)) from exc
|
|
232
|
+
for query, ids in zip(missing, answered):
|
|
233
|
+
found[query] = ids
|
|
234
|
+
if ids is not None:
|
|
235
|
+
cache[key(query)] = {"ids": ids, "at": now}
|
|
236
|
+
cache = {key: entry for key, entry in cache.items() if _fresh_ids(entry, now) is not None}
|
|
237
|
+
try:
|
|
238
|
+
os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
|
|
239
|
+
atomic_io.write_secret_text(CACHE_PATH, json.dumps(cache, indent=1))
|
|
240
|
+
except OSError as exc:
|
|
241
|
+
return [found[query] for query in queries], f"advisory cache not saved: {exc}"
|
|
242
|
+
return [found[query] for query in queries], ""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def notice(before, after, ecosystem="npm"):
|
|
246
|
+
"""The dependency notice for one manifest edit.
|
|
247
|
+
|
|
248
|
+
Parameters: before (str or None) file text before the edit; after (str) text after it; ecosystem (str) one of
|
|
249
|
+
ECOSYSTEMS, from `ecosystem_of`.
|
|
250
|
+
Returns: str, "Smart Tool dependencies: ..." lines for the changed dependencies that have advisories or could not
|
|
251
|
+
be verified, "" when there is nothing to say.
|
|
252
|
+
Raises: DependencyCheckError when the check could not run.
|
|
253
|
+
"""
|
|
254
|
+
spec = _ECOSYSTEMS[ecosystem]
|
|
255
|
+
changed = changed_dependencies(before, after, ecosystem)
|
|
256
|
+
handled, extra = changed[:MAX_PACKAGES], len(changed) - MAX_PACKAGES
|
|
257
|
+
lines, queries = {}, []
|
|
258
|
+
for name, specifier in handled:
|
|
259
|
+
version = spec.version(specifier) if freshness.NAME_RES[ecosystem].fullmatch(name) else None
|
|
260
|
+
if version is None:
|
|
261
|
+
lines[(name, specifier)] = f"`{spec.label(name, specifier)}` specifier not verified."
|
|
262
|
+
else:
|
|
263
|
+
queries.append((name, specifier, version))
|
|
264
|
+
problem = ""
|
|
265
|
+
if queries:
|
|
266
|
+
ids, problem = _advisories(ecosystem, [(name, version) for name, _specifier, version in queries])
|
|
267
|
+
for (name, specifier, version), advisories in zip(queries, ids):
|
|
268
|
+
label = f"`{spec.label(name, specifier)}`" + ("" if spec.exact(specifier, version) else f" (checked {version})")
|
|
269
|
+
if advisories is None:
|
|
270
|
+
lines[(name, specifier)] = f"{label} advisory check incomplete, not verified."
|
|
271
|
+
elif advisories:
|
|
272
|
+
shown = ", ".join(advisories[:MAX_IDS_SHOWN])
|
|
273
|
+
more = f" and {len(advisories) - MAX_IDS_SHOWN} more" if len(advisories) > MAX_IDS_SHOWN else ""
|
|
274
|
+
lines[(name, specifier)] = f"{label} has advisory IDs {shown}{more}."
|
|
275
|
+
parts = [lines[pair] for pair in handled if pair in lines]
|
|
276
|
+
if extra > 0:
|
|
277
|
+
parts.append(f"{extra} more not checked.")
|
|
278
|
+
if problem and parts:
|
|
279
|
+
parts.append(f"({problem})")
|
|
280
|
+
return PREFIX + " ".join(parts) if parts else ""
|
package/document_text.py
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
"""Extração local de texto DOCX, sem Office, execução de macros ou acesso externo."""
|
|
1
|
+
"""Extração local de texto DOCX e PDF, sem Office, execução de macros ou acesso externo."""
|
|
2
2
|
import io
|
|
3
|
+
import json
|
|
3
4
|
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
4
7
|
import zipfile
|
|
5
8
|
import xml.etree.ElementTree as ET
|
|
6
9
|
|
|
7
|
-
VERSION =
|
|
10
|
+
VERSION = 2
|
|
11
|
+
PDF_MAX_BYTES = 20 * 1024 * 1024
|
|
12
|
+
PDF_TIMEOUT_S = 20
|
|
13
|
+
PDF_MAX_PAGES = 300
|
|
14
|
+
PDF_MAX_CHARS = 1_000_000
|
|
8
15
|
MAX_ARCHIVE_BYTES = 10 * 1024 * 1024
|
|
9
16
|
MAX_XML_BYTES = 8 * 1024 * 1024
|
|
10
17
|
MAX_TEXT_BYTES = 1024 * 1024
|
|
@@ -15,6 +22,20 @@ class DocumentError(ValueError):
|
|
|
15
22
|
pass
|
|
16
23
|
|
|
17
24
|
|
|
25
|
+
class PdfTimeout(DocumentError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PdfEncrypted(DocumentError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PdfUnreadable(DocumentError):
|
|
34
|
+
def __init__(self, message, detail):
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.detail = detail
|
|
37
|
+
|
|
38
|
+
|
|
18
39
|
class _DocumentTreeBuilder(ET.TreeBuilder):
|
|
19
40
|
def doctype(self, name, pubid, system):
|
|
20
41
|
# O callback também recusa DTD em XML UTF-16, antes de expandir entidades.
|
|
@@ -65,5 +86,95 @@ def extract_docx(data):
|
|
|
65
86
|
raise DocumentError('Could not extract text from this DOCX.') from exc
|
|
66
87
|
|
|
67
88
|
|
|
89
|
+
PDF_SCRIPT = """
|
|
90
|
+
import io, json, sys
|
|
91
|
+
import pypdf
|
|
92
|
+
reader = pypdf.PdfReader(io.BytesIO(sys.stdin.buffer.read()))
|
|
93
|
+
if reader.is_encrypted and reader.decrypt("") == pypdf.PasswordType.NOT_DECRYPTED:
|
|
94
|
+
print(json.dumps({"encrypted": True}))
|
|
95
|
+
sys.exit()
|
|
96
|
+
limit, budget = int(sys.argv[1]), int(sys.argv[2])
|
|
97
|
+
pages, size = [], 0
|
|
98
|
+
for page in reader.pages[:limit]:
|
|
99
|
+
pages.append((page.extract_text() or "").strip()[:budget - size])
|
|
100
|
+
size += len(pages[-1])
|
|
101
|
+
if size >= budget:
|
|
102
|
+
break
|
|
103
|
+
print(json.dumps({"pages": pages, "count": len(reader.pages)}))
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def run_pdf(data, timeout, max_pages, max_chars):
|
|
108
|
+
"""Text of a PDF (pypdf, pure Python), page by page, with `[page N]` markers. A hostile PDF can make pypdf parse for
|
|
109
|
+
minutes, so it runs in an isolated subprocess (`-I`) killed at the deadline.
|
|
110
|
+
|
|
111
|
+
Parameters: data (PDF bytes), timeout (seconds before the subprocess is killed), max_pages, max_chars (cap on the
|
|
112
|
+
text read, over all pages). Returns: the marked text, with a `[pages A-B not read]` line when a cap cut it; empty
|
|
113
|
+
pages are left out. Raises: PdfTimeout, PdfEncrypted (a password is needed), PdfUnreadable (corrupt; `detail` has
|
|
114
|
+
the last line pypdf reported), all DocumentError."""
|
|
115
|
+
try:
|
|
116
|
+
done = subprocess.run([sys.executable, "-I", "-c", PDF_SCRIPT, str(max_pages), str(max_chars)],
|
|
117
|
+
input=data, capture_output=True, timeout=timeout,
|
|
118
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
119
|
+
except subprocess.TimeoutExpired:
|
|
120
|
+
raise PdfTimeout(f"PDF not read in {timeout:.0f}s.") from None
|
|
121
|
+
if done.returncode:
|
|
122
|
+
detail = (done.stderr.decode("utf-8", "replace").strip().splitlines() or ["no output"])[-1]
|
|
123
|
+
raise PdfUnreadable("PDF could not be read.", detail)
|
|
124
|
+
try:
|
|
125
|
+
result = json.loads(done.stdout)
|
|
126
|
+
if result.get("encrypted"):
|
|
127
|
+
raise PdfEncrypted("PDF protected by a password.")
|
|
128
|
+
pages, count = result["pages"], result["count"]
|
|
129
|
+
except (ValueError, KeyError, AttributeError) as exc:
|
|
130
|
+
if isinstance(exc, PdfEncrypted):
|
|
131
|
+
raise
|
|
132
|
+
raise PdfUnreadable("PDF could not be read.", f"unexpected extractor output ({type(exc).__name__})") from None
|
|
133
|
+
text = "\n\n".join(f"[page {number}]\n{page}" for number, page in enumerate(pages, 1) if page)
|
|
134
|
+
if count > len(pages):
|
|
135
|
+
text += f"\n\n[pages {len(pages) + 1}-{count} not read]"
|
|
136
|
+
return text
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def extract_pdf(data):
|
|
140
|
+
"""Indexable text of a PDF through run_pdf with the indexer's deadline and caps.
|
|
141
|
+
|
|
142
|
+
Parameters: data (PDF bytes). Returns: UTF-8 bytes of the marked text. Raises: DocumentError with a readable reason
|
|
143
|
+
for a timeout, a password, a corrupt file or a PDF without a text layer (scanned; there is no OCR)."""
|
|
144
|
+
try:
|
|
145
|
+
text = run_pdf(data, PDF_TIMEOUT_S, PDF_MAX_PAGES, PDF_MAX_CHARS)
|
|
146
|
+
except PdfUnreadable as exc:
|
|
147
|
+
raise PdfUnreadable(f"PDF could not be read ({exc.detail})"[:200], exc.detail) from None
|
|
148
|
+
if "[page " not in text:
|
|
149
|
+
raise DocumentError("PDF has no text layer (scanned pages are not read).")
|
|
150
|
+
return (text + "\n").encode("utf-8", "replace")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_extracted(path):
|
|
154
|
+
"""Whether the file type goes through a text extractor before indexing.
|
|
155
|
+
|
|
156
|
+
Parameters: path (any file name or path). Returns: True for `.docx` and `.pdf`, case-insensitive."""
|
|
157
|
+
return str(path).lower().endswith(('.docx', '.pdf'))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def size_limit(path):
|
|
161
|
+
"""Largest file size, in bytes, the extractor of this type accepts.
|
|
162
|
+
|
|
163
|
+
Parameters: path of an extracted type (see is_extracted). Returns: the byte limit of its extractor."""
|
|
164
|
+
return PDF_MAX_BYTES if str(path).lower().endswith('.pdf') else MAX_ARCHIVE_BYTES
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def extract(path, data):
|
|
168
|
+
"""Text of an extracted-type file.
|
|
169
|
+
|
|
170
|
+
Parameters: path (decides the type, see is_extracted), data (file bytes). Returns: UTF-8 text bytes. Raises:
|
|
171
|
+
DocumentError with a readable reason."""
|
|
172
|
+
return extract_pdf(data) if str(path).lower().endswith('.pdf') else extract_docx(data)
|
|
173
|
+
|
|
174
|
+
|
|
68
175
|
def location_kind(path):
|
|
69
|
-
|
|
176
|
+
"""Kind of line numbers a result of this file carries.
|
|
177
|
+
|
|
178
|
+
Parameters: path. Returns: `extracted_text_line` (lines of the extracted text, not of the file) for extracted
|
|
179
|
+
types, else `source_line`."""
|
|
180
|
+
return 'extracted_text_line' if is_extracted(path) else 'source_line'
|
package/freshness.py
CHANGED
|
@@ -72,34 +72,79 @@ def npm_registry_evidence(package):
|
|
|
72
72
|
return None
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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._~/-]*")}
|
|
78
|
+
|
|
79
|
+
|
|
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}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _osv_request(endpoint, payload):
|
|
86
|
+
"""The POST request to an OSV endpoint (`query` or `querybatch`) with the product's headers and User-Agent."""
|
|
87
|
+
return urllib.request.Request(
|
|
88
|
+
"https://api.osv.dev/v1/" + endpoint, data=json.dumps(payload).encode("utf-8"), method="POST",
|
|
84
89
|
headers={"Content-Type": "application/json", "Accept": "application/json",
|
|
85
90
|
"User-Agent": version.user_agent(config.load_config().get("contact"))},
|
|
86
91
|
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _osv_ids(data):
|
|
95
|
+
"""Sorted advisory IDs of one OSV result object; None when it is incomplete (next_page_token) or malformed, since
|
|
96
|
+
neither confirms the absence of a record."""
|
|
97
|
+
if isinstance(data, dict) and data.get("next_page_token"):
|
|
98
|
+
return None # Página incompleta não confirma ausência de aviso novo.
|
|
99
|
+
rows = data.get("vulns", []) if isinstance(data, dict) else None
|
|
100
|
+
if not isinstance(rows, list):
|
|
101
|
+
return None
|
|
102
|
+
ids = [row.get("id") for row in rows if isinstance(row, dict)]
|
|
103
|
+
if any(not isinstance(advisory, str) or not advisory for advisory in ids):
|
|
104
|
+
return None
|
|
105
|
+
return sorted(set(ids))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def npm_advisories(package, package_version):
|
|
109
|
+
"""IDs conhecidos pela OSV para a versão; None significa checagem indisponível."""
|
|
110
|
+
if not package or not package_version or not NPM_NAME_RE.fullmatch(package):
|
|
111
|
+
return None
|
|
112
|
+
request = _osv_request("query", _osv_query("npm", package, package_version))
|
|
87
113
|
try:
|
|
88
114
|
with urllib.request.urlopen(request, timeout=7) as response:
|
|
89
|
-
|
|
90
|
-
if isinstance(data, dict) and data.get("next_page_token"):
|
|
91
|
-
return None # Página incompleta não confirma ausência de aviso novo.
|
|
92
|
-
rows = data.get("vulns", []) if isinstance(data, dict) else None
|
|
93
|
-
if not isinstance(rows, list):
|
|
94
|
-
return None
|
|
95
|
-
ids = [row.get("id") for row in rows if isinstance(row, dict)]
|
|
96
|
-
if any(not isinstance(advisory, str) or not advisory for advisory in ids):
|
|
97
|
-
return None
|
|
98
|
-
return sorted(set(ids))
|
|
115
|
+
return _osv_ids(json.load(response))
|
|
99
116
|
except Exception:
|
|
100
117
|
return None
|
|
101
118
|
|
|
102
119
|
|
|
120
|
+
def advisories_batch(ecosystem, packages, timeout):
|
|
121
|
+
"""Advisory IDs for each (package, version) of `packages` from one OSV `querybatch` call.
|
|
122
|
+
|
|
123
|
+
Parameters: ecosystem (str) "npm", "PyPI" or "Go"; packages (list of (name, version)); timeout (float) seconds for
|
|
124
|
+
the request.
|
|
125
|
+
Returns: list in the same order, each the sorted ID list or None when that package's answer is incomplete
|
|
126
|
+
(next_page_token) or malformed, so it is unverified and not clean.
|
|
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.
|
|
129
|
+
"""
|
|
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)
|
|
135
|
+
for name, package_version in packages]})
|
|
136
|
+
try:
|
|
137
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
138
|
+
data = json.load(response)
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
raise RuntimeError(f"OSV querybatch failed: {exc}") from exc
|
|
141
|
+
results = data.get("results") if isinstance(data, dict) else None
|
|
142
|
+
if not isinstance(results, list) or len(results) != len(packages):
|
|
143
|
+
raise RuntimeError(f"OSV querybatch answered {len(results) if isinstance(results, list) else 'no'} results "
|
|
144
|
+
f"for {len(packages)} queries")
|
|
145
|
+
return [_osv_ids(result) for result in results]
|
|
146
|
+
|
|
147
|
+
|
|
103
148
|
def npm_osv_evidence(package, version):
|
|
104
149
|
ids = npm_advisories(package, version)
|
|
105
150
|
if ids is None:
|