weavatrix 0.2.7 → 0.2.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/README.md +19 -0
- package/docs/releases/v0.2.8.md +82 -0
- package/package.json +2 -2
- package/src/analysis/dead-check.js +24 -2
- package/src/analysis/dep-check-ecosystems.js +41 -2
- package/src/analysis/endpoints.js +47 -13
- package/src/analysis/internal-audit.collect.js +53 -20
- package/src/graph/builder/lang-rust.js +49 -12
- package/src/mcp/graph-context.mjs +36 -1
- package/src/mcp/tools-health.mjs +22 -1
- package/src/mcp/tools-verified-change.mjs +16 -0
- package/src/path-classification.js +1 -1
- package/src/precision/symbol-query.js +51 -0
- package/docs/releases/v0.2.7.md +0 -93
package/README.md
CHANGED
|
@@ -216,6 +216,25 @@ disclosed instead of silently guessed; and the server **hot-reloads its watched
|
|
|
216
216
|
modules and catalog** when those files change — other MCP helpers and analysis engines require a
|
|
217
217
|
reconnect.
|
|
218
218
|
|
|
219
|
+
### 0.2.8 trust and precision patch
|
|
220
|
+
|
|
221
|
+
- Dead-code review no longer labels a declaration `test-only` when it has a same-file production
|
|
222
|
+
use. Revision-bound positive evidence from an exact `inspect_symbol` point query also removes that
|
|
223
|
+
declaration from later dead-code candidates instead of remaining isolated in its cache.
|
|
224
|
+
- Python dependency checks discover nested `requirements*.txt`, `requirements/*.txt`,
|
|
225
|
+
`pyproject.toml`, and `Pipfile` manifests and assign imports to their nearest manifest scope.
|
|
226
|
+
A root `test.py` is classified consistently as test code.
|
|
227
|
+
- Endpoint extraction masks commented-out routes without shifting source offsets and rejects
|
|
228
|
+
primitive path-to-name maps such as `{'/.codex': 'claude'}` while retaining real route tables.
|
|
229
|
+
- Rust symbols now expose concrete kinds, owner types, visibility/export state, selection ranges and
|
|
230
|
+
structural owner/member edges. This is parser evidence; Rust semantic/LSP precision remains
|
|
231
|
+
explicitly unavailable.
|
|
232
|
+
- Natural-language retrieval honors explicit Rust, Python, TypeScript, JavaScript, Go, Java, and C#
|
|
233
|
+
intent in mixed repositories. Compact `verified_change` text now includes decisive exact reference
|
|
234
|
+
counts and bounded inbound caller names for every selected edit symbol.
|
|
235
|
+
|
|
236
|
+
Full patch notes: [docs/releases/v0.2.8.md](docs/releases/v0.2.8.md).
|
|
237
|
+
|
|
219
238
|
### 0.2.7 verified-change workflow
|
|
220
239
|
|
|
221
240
|
- New `verified_change` composes task retrieval, exact symbol context, change impact, immutable Git
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Weavatrix 0.2.8
|
|
2
|
+
|
|
3
|
+
## Trust before surface area
|
|
4
|
+
|
|
5
|
+
0.2.8 is a correctness release. It does not add another MCP tool. It fixes false positives and
|
|
6
|
+
evidence hand-off failures found while running 0.2.7 against current JavaScript, TypeScript, Python,
|
|
7
|
+
and Rust repositories.
|
|
8
|
+
|
|
9
|
+
## Dead-code liveness
|
|
10
|
+
|
|
11
|
+
- A production declaration is no longer classified as `test-only` merely because the same identifier
|
|
12
|
+
appears in tests. Same-file occurrences beyond the indexed declaration count as conservative
|
|
13
|
+
production-use evidence.
|
|
14
|
+
- Exact positive point-query results are now revision/config fingerprint checked and consumed by
|
|
15
|
+
`find_dead_code`. If `inspect_symbol` found a reference, the symbol cannot remain in the dead queue.
|
|
16
|
+
- Complete exact zero-reference point results can still raise internal candidates to strong static
|
|
17
|
+
evidence. Partial, stale, malformed, or configuration-drifted cache entries fail closed.
|
|
18
|
+
|
|
19
|
+
These remain review candidates. Static absence never authorizes automatic deletion.
|
|
20
|
+
|
|
21
|
+
## Python manifest ownership
|
|
22
|
+
|
|
23
|
+
Python dependency collection now discovers nested `requirements*.txt`, `requirements/*.txt`,
|
|
24
|
+
`pyproject.toml`, and `Pipfile` manifests. Each import is judged against its nearest ancestor Python
|
|
25
|
+
manifest. A nested service therefore sees its own declared dependencies, while unrelated sibling code
|
|
26
|
+
cannot inherit them accidentally.
|
|
27
|
+
|
|
28
|
+
The conventional root filename `test.py` is now classified as test code by both path classification
|
|
29
|
+
and dependency severity logic.
|
|
30
|
+
|
|
31
|
+
## Endpoint false-positive guards
|
|
32
|
+
|
|
33
|
+
Endpoint extraction masks line and block comments while preserving source offsets. Commented-out
|
|
34
|
+
Express/Hono/Go/Rust/Java-style routes no longer enter the inventory.
|
|
35
|
+
|
|
36
|
+
Direct object routes also require an executable handler value. Primitive path maps such as
|
|
37
|
+
`{'/.codex': 'claude'}` are configuration data, not `ANY /.codex` endpoints.
|
|
38
|
+
|
|
39
|
+
## Rust graph metadata
|
|
40
|
+
|
|
41
|
+
Rust functions, methods, structs, enums, traits, types, modules, constants, statics, macros, and unions
|
|
42
|
+
now carry concrete `symbol_kind` values. Impl/trait functions retain `member_of`, visibility, selection
|
|
43
|
+
ranges and structural owner/member edges; public module declarations retain exported state.
|
|
44
|
+
|
|
45
|
+
This improves graph navigation and dead-code classification but remains tree-sitter parser evidence.
|
|
46
|
+
0.2.8 does not bundle rust-analyzer and does not claim exact Rust references.
|
|
47
|
+
|
|
48
|
+
## Mixed-language retrieval and compact proof
|
|
49
|
+
|
|
50
|
+
`query_graph` and workflows that reuse its seed ranking constrain explicit Rust, Python, TypeScript,
|
|
51
|
+
JavaScript, Go, Java, or C# questions to matching source extensions. Multiple explicitly named
|
|
52
|
+
languages form a union; exact `seed_files` continue to be the strongest pinning mechanism.
|
|
53
|
+
|
|
54
|
+
Compact `verified_change` output now lists each selected symbol's exact/bounded reference occurrence
|
|
55
|
+
count, referenced-file count, inbound-container count, and up to three inbound names. The short form
|
|
56
|
+
therefore cannot hide that a supposedly unused symbol has a known caller.
|
|
57
|
+
|
|
58
|
+
## Scope and remaining competitor gaps
|
|
59
|
+
|
|
60
|
+
The local engine still has no vector embedding search, runtime trace ingestion, clone-to-ADR layer,
|
|
61
|
+
Python/Rust/Java LSP integration, whole-program CFG, value propagation, or taint engine. Those are
|
|
62
|
+
separate precision projects, not claims attached to this patch release. Independent end-to-end
|
|
63
|
+
competitor scores also remain missing until collected under the blind benchmark protocol.
|
|
64
|
+
|
|
65
|
+
## Verification
|
|
66
|
+
|
|
67
|
+
- Full Node test suite: 487 tests, 484 passed, 3 platform skips, 0 failed.
|
|
68
|
+
- Deterministic language/cross-repository/lifecycle benchmark: `PASS`.
|
|
69
|
+
- Real pinned corpus: 6 of 6 repositories `PASS` across TypeScript, JavaScript, Python, Go, Java,
|
|
70
|
+
and Rust.
|
|
71
|
+
- Agent routing microbenchmark: 3 of 3 expected symbols, 0 false positives. Independent competitor
|
|
72
|
+
comparison remains `INCOMPLETE` because no blind same-task result was supplied.
|
|
73
|
+
- npm package dry-run succeeds for `weavatrix@0.2.8`; the portable MCPB validates, initializes its
|
|
74
|
+
staged TypeScript precision provider, passes the repository-plugin execution guard, and packs.
|
|
75
|
+
- Direct dogfood checks confirm the original regressions: local `USER_HEADER` is not dead/test-only;
|
|
76
|
+
nested `autobot/requirements.txt` covers `slack_bolt`; commented `DELETE /:id` and primitive
|
|
77
|
+
`ANY /.codex` are absent; explicit Rust retrieval returns only `.rs` seeds.
|
|
78
|
+
|
|
79
|
+
## Tool catalog
|
|
80
|
+
|
|
81
|
+
0.2.8 exposes the same 36 MCP tools as 0.2.7. The default offline profile exposes 33; pinned exposes
|
|
82
|
+
30. Network tools remain absent from offline and pinned profiles.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weavatrix",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Weavatrix — code graph & blast-radius MCP server for AI coding agents: change impact, transitive dependents, health audit, duplicate detection, and coverage mapping over any local repository.",
|
|
6
6
|
"author": "Sergii Ziborov <sergii.ziborov@gmail.com>",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"skill",
|
|
27
27
|
"scripts/run-agent-task-benchmark.mjs",
|
|
28
28
|
"docs/agent-task-benchmark.md",
|
|
29
|
-
"docs/releases/v0.2.
|
|
29
|
+
"docs/releases/v0.2.8.md",
|
|
30
30
|
"README.md",
|
|
31
31
|
"SECURITY.md",
|
|
32
32
|
"LICENSE"
|
|
@@ -126,14 +126,29 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
|
|
|
126
126
|
|
|
127
127
|
const symbolNames = new Set([...symById.values()].map((node) => bareName(node.label)).filter(Boolean));
|
|
128
128
|
const occurrenceFiles = new Map();
|
|
129
|
+
const occurrenceCounts = new Map();
|
|
129
130
|
for (const [file, text] of sources) for (const match of String(text || "").matchAll(IDENT_RE)) {
|
|
130
131
|
const name = match[0];
|
|
131
132
|
if (!symbolNames.has(name)) continue;
|
|
132
133
|
const files = occurrenceFiles.get(name) || new Set();
|
|
133
134
|
files.add(file);
|
|
134
135
|
occurrenceFiles.set(name, files);
|
|
136
|
+
const counts = occurrenceCounts.get(name) || new Map();
|
|
137
|
+
counts.set(file, (counts.get(file) || 0) + 1);
|
|
138
|
+
occurrenceCounts.set(name, counts);
|
|
135
139
|
}
|
|
136
140
|
|
|
141
|
+
const declarationCounts = new Map();
|
|
142
|
+
for (const node of symById.values()) {
|
|
143
|
+
const name = bareName(node.label);
|
|
144
|
+
if (!name) continue;
|
|
145
|
+
const key = `${node.source_file}\0${name}`;
|
|
146
|
+
declarationCounts.set(key, (declarationCounts.get(key) || 0) + 1);
|
|
147
|
+
}
|
|
148
|
+
const exactReferenceIds = new Set(graph.precisionReferenceSymbols || []);
|
|
149
|
+
const exactProductionReferenceIds = new Set(graph.precisionProductionReferenceSymbols || []);
|
|
150
|
+
const exactTestReferenceIds = new Set(graph.precisionTestReferenceSymbols || []);
|
|
151
|
+
|
|
137
152
|
// decorated defs (@app.route/@app.event/@pytest.fixture…) are entered by the framework: trust the
|
|
138
153
|
// builder's flag when present, else walk the source line(s) above the definition (graph-builder graphs).
|
|
139
154
|
const lineCache = new Map();
|
|
@@ -155,6 +170,7 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
|
|
|
155
170
|
|
|
156
171
|
const isReferenced = (n) => {
|
|
157
172
|
if (inbound.has(n.id)) return true; // a real graph edge targets it
|
|
173
|
+
if (exactReferenceIds.has(String(n.id))) return true; // revision-bound point-query evidence found a caller
|
|
158
174
|
const name = bareName(n.label);
|
|
159
175
|
if (!name || !/^[A-Za-z_$]/.test(name)) return true; // selectors/odd labels → don't flag
|
|
160
176
|
if (/^__\w+__$/.test(name)) return true; // dunders are invoked implicitly (with/str/==/iter…), never spelled
|
|
@@ -182,8 +198,14 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
|
|
|
182
198
|
const name = bareName(n.label);
|
|
183
199
|
const occurrenceSet = occurrenceFiles.get(name) || new Set();
|
|
184
200
|
const externalOccurrences = [...occurrenceSet].filter((file) => file !== n.source_file);
|
|
201
|
+
const localOccurrences = occurrenceCounts.get(name)?.get(n.source_file) || 0;
|
|
202
|
+
const localDeclarations = declarationCounts.get(`${n.source_file}\0${name}`) || 0;
|
|
203
|
+
const hasLocalProductionUse = localOccurrences > localDeclarations;
|
|
185
204
|
const lexicalTestOnly = externalOccurrences.length > 0 && externalOccurrences.every((file) => isTestFile(file));
|
|
186
|
-
|
|
205
|
+
const hasExactProductionInbound = exactProductionReferenceIds.has(String(n.id));
|
|
206
|
+
const hasExactTestInbound = exactTestReferenceIds.has(String(n.id));
|
|
207
|
+
if (hasProductionInbound || hasExactProductionInbound || hasLocalProductionUse
|
|
208
|
+
|| (!hasTestInbound && !hasExactTestInbound && !lexicalTestOnly)) continue;
|
|
187
209
|
testOnlySymbols.push({
|
|
188
210
|
id: n.id,
|
|
189
211
|
file: n.source_file,
|
|
@@ -191,7 +213,7 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
|
|
|
191
213
|
test: false,
|
|
192
214
|
reason: "referenced only from test/e2e code; no production consumer was found",
|
|
193
215
|
testConsumerFiles: [...new Set(sourceFiles.filter((file) => file && isTestFile(file)))].sort(),
|
|
194
|
-
evidence: hasTestInbound ? "graph" : "lexical",
|
|
216
|
+
evidence: hasTestInbound ? "graph" : hasExactTestInbound ? "exact-semantic" : "lexical",
|
|
195
217
|
publicApi: n.exported === true || ["public", "protected"].includes(String(n.visibility || "").toLowerCase()),
|
|
196
218
|
});
|
|
197
219
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { makeFinding } from "./findings.js";
|
|
2
2
|
|
|
3
|
-
const TEST_PATH_RE = /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z0-9]+$|_test\.go$/i;
|
|
3
|
+
const TEST_PATH_RE = /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z0-9]+$|_test\.go$|(^|[/\\])test(?:_[^/\\]*)?\.py$/i;
|
|
4
4
|
const escRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5
5
|
const mentioned = (blob, name) => new RegExp(`(^|[^\\w@.-])${escRe(name)}($|[^\\w.-])`).test(blob);
|
|
6
6
|
|
|
@@ -79,7 +79,7 @@ const PY_TOOL_DISTS = new Set(("pytest tox nox black ruff flake8 pylint mypy pyr
|
|
|
79
79
|
"pip-tools uv virtualenv pipenv gunicorn uwsgi supervisor ipython jupyter jupyterlab notebook ipykernel codecov autopep8 yapf commitizen detect-secrets safety pip-audit hatchling flit flit-core pdm").split(" "));
|
|
80
80
|
const pyNorm = (n) => String(n || "").toLowerCase().replace(/[-_.]+/g, "-");
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
function computePyDepFindingsFlat({
|
|
83
83
|
externalImports = [], pyManifest = null, configTexts = new Map(),
|
|
84
84
|
managedDependencies = [], ignoredDependencies = [], nonRuntimeRoots = [],
|
|
85
85
|
} = {}) {
|
|
@@ -161,3 +161,42 @@ export function computePyDepFindings({
|
|
|
161
161
|
}
|
|
162
162
|
return { findings, declared, managed };
|
|
163
163
|
}
|
|
164
|
+
|
|
165
|
+
const normPyScope = (root) => String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
|
|
166
|
+
const pyScopeOwns = (root, file) => !root || file === root || String(file || "").replace(/\\/g, "/").startsWith(`${root}/`);
|
|
167
|
+
|
|
168
|
+
export function computePyDepFindings(options = {}) {
|
|
169
|
+
const scopes = Array.isArray(options.pyManifest?.scopes) ? options.pyManifest.scopes : [];
|
|
170
|
+
if (!scopes.length) return computePyDepFindingsFlat(options);
|
|
171
|
+
const normalized = scopes.map((scope) => ({ ...scope, root: normPyScope(scope.root) }))
|
|
172
|
+
.sort((left, right) => right.root.length - left.root.length);
|
|
173
|
+
if (!normalized.some((scope) => !scope.root)) normalized.push({ root: "", present: false, deps: [], manifests: [] });
|
|
174
|
+
const importsByScope = new Map(normalized.map((scope) => [scope, []]));
|
|
175
|
+
const configByScope = new Map(normalized.map((scope) => [scope, new Map()]));
|
|
176
|
+
for (const entry of options.externalImports || []) {
|
|
177
|
+
const owner = normalized.find((scope) => pyScopeOwns(scope.root, entry.file)) || normalized.at(-1);
|
|
178
|
+
importsByScope.get(owner).push(entry);
|
|
179
|
+
}
|
|
180
|
+
for (const [file, text] of options.configTexts || new Map()) {
|
|
181
|
+
const owner = normalized.find((scope) => pyScopeOwns(scope.root, file)) || normalized.at(-1);
|
|
182
|
+
configByScope.get(owner).set(file, text);
|
|
183
|
+
}
|
|
184
|
+
const findings = [];
|
|
185
|
+
const declared = new Set();
|
|
186
|
+
const managed = new Set();
|
|
187
|
+
for (const scope of normalized) {
|
|
188
|
+
const result = computePyDepFindingsFlat({
|
|
189
|
+
...options,
|
|
190
|
+
externalImports: importsByScope.get(scope),
|
|
191
|
+
configTexts: configByScope.get(scope),
|
|
192
|
+
pyManifest: {present: scope.present, deps: scope.deps || []},
|
|
193
|
+
});
|
|
194
|
+
findings.push(...result.findings.map((finding) => ({
|
|
195
|
+
...finding,
|
|
196
|
+
...(scope.manifests?.length ? {manifest: scope.manifests[0]} : {}),
|
|
197
|
+
})));
|
|
198
|
+
for (const name of result.declared) declared.add(`${scope.root || "."}:${name}`);
|
|
199
|
+
for (const name of result.managed) managed.add(name);
|
|
200
|
+
}
|
|
201
|
+
return { findings, declared, managed };
|
|
202
|
+
}
|
|
@@ -27,6 +27,38 @@ function lineAt(text, index) {
|
|
|
27
27
|
return line;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Regex extractors must never see commented-out routes. Preserve string literals and every source
|
|
31
|
+
// offset, but replace comment bodies with spaces so endpoint line numbers still refer to the original
|
|
32
|
+
// file. Python `#` comments are enabled only for .py files; Rust attributes such as #[get] stay intact.
|
|
33
|
+
function maskComments(text, { hashComments = false } = {}) {
|
|
34
|
+
const chars = String(text || "").split("");
|
|
35
|
+
let quote = "", escaped = false, lineComment = false, blockComment = false;
|
|
36
|
+
for (let i = 0; i < chars.length; i++) {
|
|
37
|
+
const ch = chars[i], next = chars[i + 1];
|
|
38
|
+
if (lineComment) {
|
|
39
|
+
if (ch === "\n" || ch === "\r") lineComment = false;
|
|
40
|
+
else chars[i] = " ";
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (blockComment) {
|
|
44
|
+
if (ch === "*" && next === "/") { chars[i] = chars[i + 1] = " "; i++; blockComment = false; }
|
|
45
|
+
else if (ch !== "\n" && ch !== "\r") chars[i] = " ";
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (quote) {
|
|
49
|
+
if (escaped) escaped = false;
|
|
50
|
+
else if (ch === "\\") escaped = true;
|
|
51
|
+
else if (ch === quote) quote = "";
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (ch === '"' || ch === "'" || ch === "`") { quote = ch; continue; }
|
|
55
|
+
if (ch === "/" && next === "/") { chars[i] = chars[i + 1] = " "; i++; lineComment = true; continue; }
|
|
56
|
+
if (ch === "/" && next === "*") { chars[i] = chars[i + 1] = " "; i++; blockComment = true; continue; }
|
|
57
|
+
if (hashComments && ch === "#") { chars[i] = " "; lineComment = true; }
|
|
58
|
+
}
|
|
59
|
+
return chars.join("");
|
|
60
|
+
}
|
|
61
|
+
|
|
30
62
|
// best-effort bare handler name from a value expression: the LAST identifier, unwrapping wrappers like
|
|
31
63
|
// executionRoute(queryHandlers.executeQuery) → executeQuery, asyncHandler(fn) → fn, a.b.c → c.
|
|
32
64
|
// An INLINE handler (arrow / function literal) has no named method to join to, so it returns "" (the
|
|
@@ -75,6 +107,7 @@ export function extractEndpointsFromText(text, file) {
|
|
|
75
107
|
const py = /\.py$/i.test(file);
|
|
76
108
|
const rust = /\.rs$/i.test(file);
|
|
77
109
|
const java = /\.java$/i.test(file);
|
|
110
|
+
const scanText = maskComments(text, { hashComments: py });
|
|
78
111
|
const add = (method, path, expr, idx) => {
|
|
79
112
|
const p = cleanPath(path);
|
|
80
113
|
if (!looksLikePath(p)) return;
|
|
@@ -90,13 +123,13 @@ export function extractEndpointsFromText(text, file) {
|
|
|
90
123
|
const seen = new Set();
|
|
91
124
|
const direct = /\bexport\s+(?:(?:async|declare)\s+)*(?:function\s+|(?:const|let|var)\s+)(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/g;
|
|
92
125
|
let nm;
|
|
93
|
-
while ((nm = direct.exec(
|
|
126
|
+
while ((nm = direct.exec(scanText))) {
|
|
94
127
|
const method = nm[1].toUpperCase();
|
|
95
128
|
if (!seen.has(method)) { seen.add(method); add(method, nextPath, method, nm.index); }
|
|
96
129
|
}
|
|
97
130
|
const lists = /\bexport\s*\{([^}]+)\}/g;
|
|
98
131
|
let lm;
|
|
99
|
-
while ((lm = lists.exec(
|
|
132
|
+
while ((lm = lists.exec(scanText))) {
|
|
100
133
|
for (const item of lm[1].split(",")) {
|
|
101
134
|
const mm = /^\s*([A-Za-z_$][\w$]*)(?:\s+as\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS))?\s*$/.exec(item);
|
|
102
135
|
if (!mm) continue;
|
|
@@ -108,9 +141,9 @@ export function extractEndpointsFromText(text, file) {
|
|
|
108
141
|
}
|
|
109
142
|
}
|
|
110
143
|
|
|
111
|
-
if (rust) extractRustEndpoints(
|
|
144
|
+
if (rust) extractRustEndpoints(scanText, add);
|
|
112
145
|
if (java) {
|
|
113
|
-
out.push(...extractSpringEndpoints(
|
|
146
|
+
out.push(...extractSpringEndpoints(scanText, file));
|
|
114
147
|
return out; // generic JS-style method calls would turn Java HTTP clients into fake server routes
|
|
115
148
|
}
|
|
116
149
|
|
|
@@ -118,14 +151,14 @@ export function extractEndpointsFromText(text, file) {
|
|
|
118
151
|
// find each "…": { or "…": expr, where the key looks like a path
|
|
119
152
|
const objKeyRe = /(["'`])(\/[^"'`]*)\1\s*:\s*(\{)?/g;
|
|
120
153
|
let m;
|
|
121
|
-
while ((m = objKeyRe.exec(
|
|
154
|
+
while ((m = objKeyRe.exec(scanText))) {
|
|
122
155
|
const path = m[2], keyIdx = m.index;
|
|
123
156
|
if (m[3]) {
|
|
124
157
|
// object of METHOD: handler — scan to the matching close brace (routes objects are shallow)
|
|
125
158
|
let i = objKeyRe.lastIndex, depth = 1;
|
|
126
159
|
const start = i;
|
|
127
|
-
while (i <
|
|
128
|
-
const body =
|
|
160
|
+
while (i < scanText.length && depth > 0) { const c = scanText[i]; if (c === "{") depth++; else if (c === "}") depth--; i++; }
|
|
161
|
+
const body = scanText.slice(start, i - 1);
|
|
129
162
|
objKeyRe.lastIndex = i;
|
|
130
163
|
if (OPENAPI_BLOCK.test(body)) continue; // documentation, not a route table
|
|
131
164
|
const methodRe = /\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b\s*:\s*([^,\n}]+)/gi;
|
|
@@ -133,9 +166,10 @@ export function extractEndpointsFromText(text, file) {
|
|
|
133
166
|
while ((mm = methodRe.exec(body))) add(mm[1], path, mm[2], keyIdx);
|
|
134
167
|
} else {
|
|
135
168
|
// "/path": handlerExpr — a direct handler (any method); grab up to the next , or }
|
|
136
|
-
const tail =
|
|
169
|
+
const tail = scanText.slice(objKeyRe.lastIndex, objKeyRe.lastIndex + 200);
|
|
137
170
|
const em = /^([^,\n}]+)/.exec(tail);
|
|
138
|
-
|
|
171
|
+
// A string/number/array value is an ordinary path/name lookup, not an executable handler.
|
|
172
|
+
if (em && !/^\s*(?:\{|["'`\[]|[-+]?\d|true\b|false\b|null\b|undefined\b)/i.test(em[1])) add("ANY", path, em[1], keyIdx);
|
|
139
173
|
}
|
|
140
174
|
}
|
|
141
175
|
|
|
@@ -146,7 +180,7 @@ export function extractEndpointsFromText(text, file) {
|
|
|
146
180
|
// FRONTEND code, not server routes. A server route also REQUIRES a handler arg (an identifier/function),
|
|
147
181
|
// so a bare `client.get("/x")` or one whose 2nd arg is a config object literal `{…}` is skipped.
|
|
148
182
|
const callRe = /(?<!@)\b([\w$]+)\s*\.\s*(get|post|put|patch|delete|head|options|all)\s*\(\s*(["'`])(\/[^"'`]*)\3\s*(?:,\s*([\s\S]{0,160}?))?\)/gi;
|
|
149
|
-
while ((m = callRe.exec(
|
|
183
|
+
while ((m = callRe.exec(scanText))) {
|
|
150
184
|
const caller = m[1], arg2 = String(m[5] || "").trim();
|
|
151
185
|
if (HTTP_CLIENT_CALLER.test(caller)) continue; // axios/http/fetch/apiClient… → a client request
|
|
152
186
|
if (!arg2 || arg2[0] === "{") continue; // no handler, or a config object → not a route def
|
|
@@ -155,14 +189,14 @@ export function extractEndpointsFromText(text, file) {
|
|
|
155
189
|
|
|
156
190
|
// ---- Go net/http: mux.HandleFunc("/path", handler) / http.Handle("/path", h) ------------------
|
|
157
191
|
const goRe = /\.\s*(?:HandleFunc|Handle)\s*\(\s*(["'`])(\/[^"'`]*)\1\s*,\s*([\s\S]{0,120}?)\)/g;
|
|
158
|
-
while ((m = goRe.exec(
|
|
192
|
+
while ((m = goRe.exec(scanText))) add("ANY", m[2], m[3], m.index);
|
|
159
193
|
|
|
160
194
|
// ---- decorators: @app.get("/path") / @router.post("/path") / @Get("/path") -------------------
|
|
161
195
|
if (py || /\.(ts|js|tsx|jsx|cjs|mjs)$/i.test(file)) {
|
|
162
196
|
const decoRe = /@[\w$]*\.?\s*(get|post|put|patch|delete|head|options)\s*\(\s*(["'`])(\/[^"'`]*)\2/gi;
|
|
163
|
-
while ((m = decoRe.exec(
|
|
197
|
+
while ((m = decoRe.exec(scanText))) {
|
|
164
198
|
// the handler is the def/function on a following line — best-effort: next def name
|
|
165
|
-
const after =
|
|
199
|
+
const after = scanText.slice(decoRe.lastIndex, decoRe.lastIndex + 200);
|
|
166
200
|
const fn = /\b(?:def|async\s+def|function|const|export\s+function)\s+([A-Za-z_$][\w$]*)/.exec(after);
|
|
167
201
|
add(m[1], m[3], fn ? fn[1] : "", m.index);
|
|
168
202
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { readFileSync, readdirSync } from "node:fs";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
-
import { parseRequirementsNames, parsePyprojectDeps, parsePipfileDeps } from "./manifests.js";
|
|
6
|
+
import { parseRequirementsNames, parsePyprojectDeps, parsePipfileDeps, pep503 } from "./manifests.js";
|
|
7
7
|
import { createRepoBoundary } from "../repo-path.js";
|
|
8
8
|
import { childProcessEnv } from "../child-env.js";
|
|
9
9
|
import { filterWeavatrixIgnored } from "../path-ignore.js";
|
|
@@ -254,28 +254,61 @@ export function workspacePkgNames(repoRoot, pkg) {
|
|
|
254
254
|
|
|
255
255
|
export const TEST_FILE_RE = /(^|[/])(test|tests|__tests__|spec|e2e|__mocks__)([/]|$)|[._-](test|spec)\.[a-z0-9]+$/i;
|
|
256
256
|
|
|
257
|
-
// Python declared deps
|
|
257
|
+
// Python declared deps are owned by the nearest manifest root, matching nested service/monorepo
|
|
258
|
+
// layouts. `requirements/*.txt` belongs to the directory above `requirements`; a colocated
|
|
259
|
+
// requirements.txt, pyproject.toml or Pipfile owns its own directory.
|
|
258
260
|
// present=false (no manifest at all) softens missing-dep findings instead of suppressing them.
|
|
259
261
|
export function collectPyManifest(repoRoot) {
|
|
260
|
-
const deps = [];
|
|
261
|
-
let present = false;
|
|
262
|
-
let names = [];
|
|
263
262
|
const boundary = createRepoBoundary(repoRoot);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
263
|
+
const scopes = new Map();
|
|
264
|
+
const scopeFor = (root) => {
|
|
265
|
+
const normalized = normRoot(root);
|
|
266
|
+
if (!scopes.has(normalized)) scopes.set(normalized, { root: normalized, present: false, deps: [], manifests: [] });
|
|
267
|
+
return scopes.get(normalized);
|
|
268
|
+
};
|
|
269
|
+
const addManifest = (root, manifest, parsedDeps, present = true) => {
|
|
270
|
+
if (!present) return;
|
|
271
|
+
const scope = scopeFor(root);
|
|
272
|
+
scope.present = true;
|
|
273
|
+
scope.manifests.push(manifest);
|
|
274
|
+
scope.deps.push(...parsedDeps.map((dep) => ({ ...dep, manifest })));
|
|
275
|
+
};
|
|
276
|
+
const files = listRepoFiles(repoRoot);
|
|
277
|
+
for (const file of files.filter((name) => /(^|\/)requirements[\w.-]*\.(?:txt|in)$/i.test(name)
|
|
278
|
+
|| /(^|\/)requirements\/[^/]+\.(?:txt|in)$/i.test(name))) {
|
|
279
|
+
const t = readRepoText(boundary, file);
|
|
271
280
|
if (t == null) continue;
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
281
|
+
const parent = normRoot(dirname(file));
|
|
282
|
+
const root = /(^|\/)requirements$/i.test(parent) ? normRoot(dirname(parent)) : parent;
|
|
283
|
+
const dev = /dev|test|lint|doc|ci/i.test(file.slice(file.lastIndexOf("/") + 1));
|
|
284
|
+
addManifest(root, file, parseRequirementsNames(t).map((dep) => ({ ...dep, dev })));
|
|
275
285
|
}
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
286
|
+
for (const file of files.filter((name) => /(^|\/)pyproject\.toml$/i.test(name))) {
|
|
287
|
+
const parsed = parsePyprojectDeps(readRepoText(boundary, file));
|
|
288
|
+
addManifest(dirname(file), file, parsed.deps, parsed.present);
|
|
289
|
+
}
|
|
290
|
+
for (const file of files.filter((name) => /(^|\/)Pipfile$/i.test(name))) {
|
|
291
|
+
const parsed = parsePipfileDeps(readRepoText(boundary, file));
|
|
292
|
+
addManifest(dirname(file), file, parsed.deps, parsed.present);
|
|
293
|
+
}
|
|
294
|
+
const normalizedScopes = [...scopes.values()]
|
|
295
|
+
.map((scope) => {
|
|
296
|
+
const seen = new Set();
|
|
297
|
+
return {
|
|
298
|
+
...scope,
|
|
299
|
+
manifests: [...new Set(scope.manifests)].sort(),
|
|
300
|
+
deps: scope.deps.filter((dep) => {
|
|
301
|
+
const key = pep503(dep.name);
|
|
302
|
+
if (!key || seen.has(key)) return false;
|
|
303
|
+
seen.add(key);
|
|
304
|
+
return true;
|
|
305
|
+
}),
|
|
306
|
+
};
|
|
307
|
+
})
|
|
308
|
+
.sort((left, right) => right.root.length - left.root.length || left.root.localeCompare(right.root));
|
|
309
|
+
return {
|
|
310
|
+
present: normalizedScopes.some((scope) => scope.present),
|
|
311
|
+
deps: normalizedScopes.flatMap((scope) => scope.deps),
|
|
312
|
+
scopes: normalizedScopes,
|
|
313
|
+
};
|
|
281
314
|
}
|
|
@@ -4,23 +4,43 @@
|
|
|
4
4
|
// `crate/self/super` paths resolve to repo-local .rs or */mod.rs files. External crates deliberately stay out
|
|
5
5
|
// of this adapter; Cargo dependency analysis owns them.
|
|
6
6
|
const SYMS_CORE = `
|
|
7
|
-
(function_item name: (identifier) @
|
|
8
|
-
(struct_item name: (type_identifier) @
|
|
9
|
-
(enum_item name: (type_identifier) @
|
|
10
|
-
(trait_item name: (type_identifier) @
|
|
11
|
-
(type_item name: (type_identifier) @
|
|
12
|
-
(mod_item name: (identifier) @
|
|
13
|
-
(const_item name: (identifier) @
|
|
14
|
-
(static_item name: (identifier) @
|
|
7
|
+
(function_item name: (identifier) @function)
|
|
8
|
+
(struct_item name: (type_identifier) @struct)
|
|
9
|
+
(enum_item name: (type_identifier) @enum)
|
|
10
|
+
(trait_item name: (type_identifier) @trait)
|
|
11
|
+
(type_item name: (type_identifier) @type)
|
|
12
|
+
(mod_item name: (identifier) @module)
|
|
13
|
+
(const_item name: (identifier) @constant)
|
|
14
|
+
(static_item name: (identifier) @static)`;
|
|
15
15
|
// Grammar-version-dependent node types, compiled SEPARATELY (one unknown type voids its whole query).
|
|
16
16
|
const SYMS_OPTIONAL = [
|
|
17
|
-
`(
|
|
18
|
-
`(
|
|
17
|
+
`(function_signature_item name: (identifier) @function)`,
|
|
18
|
+
`(macro_definition name: (identifier) @macro)`,
|
|
19
|
+
`(union_item name: (type_identifier) @union)`,
|
|
19
20
|
];
|
|
20
21
|
|
|
21
22
|
const cleanSegment = (part) => String(part || "").trim().replace(/^r#/, "");
|
|
22
23
|
const pathParts = (node) => String(node?.text || "").split("::").map(cleanSegment).filter(Boolean);
|
|
23
24
|
const under = (node, type) => { for (let p = node?.parent; p; p = p.parent) if (p.type === type) return true; return false; };
|
|
25
|
+
const ancestor = (node, types) => {
|
|
26
|
+
for (let parent = node?.parent; parent; parent = parent.parent) if (types.has(parent.type)) return parent;
|
|
27
|
+
return null;
|
|
28
|
+
};
|
|
29
|
+
const memberOwners = new Set(["impl_item", "trait_item"]);
|
|
30
|
+
const publicVisibility = (declaration, owner) => {
|
|
31
|
+
if (owner?.type === "trait_item") return "public";
|
|
32
|
+
const source = String(declaration?.text || "").trimStart();
|
|
33
|
+
if (/^pub\s/.test(source)) return "public";
|
|
34
|
+
if (/^pub\s*\(/.test(source)) return "protected";
|
|
35
|
+
return "private";
|
|
36
|
+
};
|
|
37
|
+
const ownerName = (owner, field) => {
|
|
38
|
+
if (!owner) return "";
|
|
39
|
+
if (owner.type === "trait_item") return field(owner, "name")?.text || "";
|
|
40
|
+
const type = field(owner, "type")?.text || "";
|
|
41
|
+
const withoutGenerics = type.replace(/<[^<>]*>/g, "");
|
|
42
|
+
return (withoutGenerics.match(/[A-Za-z_]\w*/g) || []).at(-1) || "";
|
|
43
|
+
};
|
|
24
44
|
|
|
25
45
|
function pathAttribute(modNode) {
|
|
26
46
|
for (let prev = modNode?.previousNamedSibling; prev?.type === "attribute_item"; prev = prev.previousNamedSibling) {
|
|
@@ -93,12 +113,29 @@ export default {
|
|
|
93
113
|
],
|
|
94
114
|
|
|
95
115
|
pass1(ctx) {
|
|
96
|
-
const { grammar, tree, fileRel, caps, addSym, addImportEdge, imports, resolveRustMod, resolveRustPath } = ctx;
|
|
116
|
+
const { grammar, tree, fileRel, caps, field, addSym, addImportEdge, imports, resolveRustMod, resolveRustPath, links, nameToId } = ctx;
|
|
117
|
+
const owned = [];
|
|
97
118
|
for (const src of [SYMS_CORE, ...SYMS_OPTIONAL]) {
|
|
98
119
|
for (const cap of caps(grammar, src, tree.rootNode)) {
|
|
99
|
-
|
|
120
|
+
const declaration = cap.node.parent;
|
|
121
|
+
const owner = cap.name === "function" ? ancestor(cap.node, memberOwners) : null;
|
|
122
|
+
const memberOf = ownerName(owner, field);
|
|
123
|
+
const symbolKind = cap.name === "function" ? (memberOf ? "method" : "function") : cap.name;
|
|
124
|
+
const visibility = publicVisibility(declaration, owner);
|
|
125
|
+
const id = addSym(cap.node.text, cap.node.startPosition.row + 1, cap.name === "function", {
|
|
126
|
+
sourceNode: declaration,
|
|
127
|
+
selectionNode: cap.node,
|
|
128
|
+
symbolKind,
|
|
129
|
+
...(memberOf ? { memberOf, visibility } : { moduleDeclaration: true }),
|
|
130
|
+
...(!memberOf && visibility === "public" ? { exported: true } : {}),
|
|
131
|
+
});
|
|
132
|
+
if (id && memberOf) owned.push({owner: memberOf, id});
|
|
100
133
|
}
|
|
101
134
|
}
|
|
135
|
+
for (const member of owned) {
|
|
136
|
+
const ownerId = nameToId.get(member.owner);
|
|
137
|
+
if (ownerId && ownerId !== member.id) links.push({source: ownerId, target: member.id, relation: "method", confidence: "EXTRACTED"});
|
|
138
|
+
}
|
|
102
139
|
|
|
103
140
|
// File dependency edges are intentionally unique per source/target/relation. A qualified path can be
|
|
104
141
|
// nested in another qualified path and often repeats an existing `mod`/`use`; counting occurrences would
|
|
@@ -163,6 +163,30 @@ const CLASS_QUERY_TERMS = Object.freeze({
|
|
|
163
163
|
})
|
|
164
164
|
const CODE_FILE_RE = /\.(?:[cm]?[jt]sx?|py|go|java|rs|kt|kts|cs|rb|php)$/i
|
|
165
165
|
const DATA_OR_PROSE_RE = /\.(?:json|ya?ml|toml|ini|md|mdx|rst|adoc|html?|css|scss|less|svg)$/i
|
|
166
|
+
const LANGUAGE_EXTENSIONS = Object.freeze({
|
|
167
|
+
rust: ['rs'], python: ['py', 'pyi'], typescript: ['ts', 'tsx', 'mts', 'cts'],
|
|
168
|
+
javascript: ['js', 'jsx', 'mjs', 'cjs'], go: ['go'], java: ['java'], csharp: ['cs'],
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
function requestedLanguages(query) {
|
|
172
|
+
const raw = String(query || '')
|
|
173
|
+
const words = new Set(wordsOf(query))
|
|
174
|
+
const requested = new Set()
|
|
175
|
+
if (words.has('rust')) requested.add('rust')
|
|
176
|
+
if (words.has('python') || words.has('py')) requested.add('python')
|
|
177
|
+
if (words.has('typescript') || words.has('ts') || /typescript/i.test(raw)) requested.add('typescript')
|
|
178
|
+
if (words.has('javascript') || words.has('js') || words.has('nodejs') || /javascript|node\.?js/i.test(raw)) requested.add('javascript')
|
|
179
|
+
if (words.has('golang') || /(?:^|[^A-Za-z])Go(?:[^A-Za-z]|$)/.test(raw)) requested.add('go')
|
|
180
|
+
if (words.has('java')) requested.add('java')
|
|
181
|
+
if (words.has('csharp') || words.has('dotnet') || /csharp|(?:^|[^A-Za-z])C#(?:[^A-Za-z]|$)/i.test(raw)) requested.add('csharp')
|
|
182
|
+
return new Set([...requested].flatMap((language) => LANGUAGE_EXTENSIONS[language]))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const matchesLanguage = (node, extensions) => {
|
|
186
|
+
if (!extensions.size) return true
|
|
187
|
+
const match = /\.([^.\/]+)$/.exec(sourceFileOf(node))
|
|
188
|
+
return !!match && extensions.has(match[1].toLowerCase())
|
|
189
|
+
}
|
|
166
190
|
|
|
167
191
|
const sourceFileOf = (node) => {
|
|
168
192
|
const source = node?.source_file || String(node?.id ?? '').split('#', 1)[0]
|
|
@@ -266,6 +290,14 @@ function conceptScore(g, node, concept, queryContext) {
|
|
|
266
290
|
else if (words.has(term)) match = Math.max(match, primary ? 36 : 25)
|
|
267
291
|
else if (term.length >= 4 && term !== 'tool' && term !== 'tools' && (id.includes(term) || label.includes(term))) match = Math.max(match, primary ? 12 : 7)
|
|
268
292
|
})
|
|
293
|
+
const extension = (/\.([^.\/]+)$/.exec(source) || [])[1] || ''
|
|
294
|
+
const languageConcept = {
|
|
295
|
+
rust: ['rs'], python: ['py', 'pyi'], py: ['py', 'pyi'],
|
|
296
|
+
typescript: ['ts', 'tsx', 'mts', 'cts'], ts: ['ts', 'tsx', 'mts', 'cts'],
|
|
297
|
+
javascript: ['js', 'jsx', 'mjs', 'cjs'], js: ['js', 'jsx', 'mjs', 'cjs'], nodejs: ['js', 'jsx', 'mjs', 'cjs'],
|
|
298
|
+
golang: ['go'], go: ['go'], java: ['java'], csharp: ['cs'], dotnet: ['cs'],
|
|
299
|
+
}[concept.raw]
|
|
300
|
+
if (languageConcept?.includes(extension) && queryContext.languageExtensions.has(extension)) match = Math.max(match, 58)
|
|
269
301
|
const fileNode = !isSymbol(node.id)
|
|
270
302
|
const depth = source ? source.split('/').length : 9
|
|
271
303
|
if (concept.id === 'bootstrap') match = Math.max(match, entrypointSignal(g, node, source, stem))
|
|
@@ -288,13 +320,16 @@ export function findSeeds(g, query, limit = 8, {repoRoot = null} = {}) {
|
|
|
288
320
|
const concepts = queryConcepts(query)
|
|
289
321
|
if (!concepts.length || limit <= 0) return []
|
|
290
322
|
const requestedClasses = requestedPathClasses(query)
|
|
323
|
+
const languageExtensions = requestedLanguages(query)
|
|
291
324
|
const queryContext = {
|
|
292
325
|
runtimeIntent: concepts.some((concept) => concept.id === 'bootstrap' || concept.id === 'tool-execution'),
|
|
293
326
|
maintenanceIntent: wordsOf(query).some((word) => ['script', 'scripts', 'build', 'release', 'publish', 'packaging'].includes(word)),
|
|
327
|
+
languageExtensions,
|
|
294
328
|
}
|
|
295
329
|
const classifier = createPathClassifier(repoRoot)
|
|
296
330
|
const classificationCache = new Map()
|
|
297
|
-
const rows = g.nodes.filter((node) =>
|
|
331
|
+
const rows = g.nodes.filter((node) => matchesLanguage(node, languageExtensions)
|
|
332
|
+
&& isQueryEligible(node, requestedClasses, classificationCache, classifier)).map((node) => {
|
|
298
333
|
const scores = concepts.map((concept) => conceptScore(g, node, concept, queryContext))
|
|
299
334
|
return {node, scores, total: Math.max(...scores) + scores.reduce((sum, score) => sum + score, 0) / 10}
|
|
300
335
|
})
|
package/src/mcp/tools-health.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import {toolResult} from './tool-result.mjs'
|
|
|
22
22
|
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
23
23
|
import {createRepoBoundary} from '../repo-path.js'
|
|
24
24
|
import {filterGraphForMode} from '../graph/graph-filter.js'
|
|
25
|
+
import {readCachedSymbolPrecisionEvidence} from '../precision/symbol-query.js'
|
|
25
26
|
|
|
26
27
|
const NON_PRODUCT_DUPLICATE_CLASSES = new Set(['generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
|
|
27
28
|
const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
|
|
@@ -101,7 +102,27 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
101
102
|
// candidates. It never returns an automatic-delete verdict.
|
|
102
103
|
export function tFindDeadCode(g, args, ctx) {
|
|
103
104
|
if (!ctx.repoRoot) return 'Dead-code review needs the repo root (not provided to this server).'
|
|
104
|
-
const
|
|
105
|
+
const effectiveGraph = effectiveRawGraph(ctx)
|
|
106
|
+
const pointEvidence = readCachedSymbolPrecisionEvidence({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, graph: effectiveGraph})
|
|
107
|
+
const graph = {
|
|
108
|
+
...effectiveGraph,
|
|
109
|
+
precisionReferenceSymbols: [...new Set([
|
|
110
|
+
...(effectiveGraph.precisionReferenceSymbols || []),
|
|
111
|
+
...pointEvidence.referenceSymbols,
|
|
112
|
+
])],
|
|
113
|
+
precisionProductionReferenceSymbols: [...new Set([
|
|
114
|
+
...(effectiveGraph.precisionProductionReferenceSymbols || []),
|
|
115
|
+
...pointEvidence.productionReferenceSymbols,
|
|
116
|
+
])],
|
|
117
|
+
precisionTestReferenceSymbols: [...new Set([
|
|
118
|
+
...(effectiveGraph.precisionTestReferenceSymbols || []),
|
|
119
|
+
...pointEvidence.testReferenceSymbols,
|
|
120
|
+
])],
|
|
121
|
+
precisionNoReferenceSymbols: [...new Set([
|
|
122
|
+
...(effectiveGraph.precisionNoReferenceSymbols || []),
|
|
123
|
+
...pointEvidence.noReferenceSymbols,
|
|
124
|
+
])],
|
|
125
|
+
}
|
|
105
126
|
const boundary = createRepoBoundary(ctx.repoRoot)
|
|
106
127
|
const pkg = readRepoJson(boundary, 'package.json') || {}
|
|
107
128
|
const rules = readRepoJson(boundary, '.weavatrix-deps.json') || {}
|
|
@@ -62,6 +62,21 @@ function architectureState(result) {
|
|
|
62
62
|
return result.verification.new?.length || String(result.verification.status).toUpperCase() === 'FAIL' ? 'BLOCKED' : 'PASS'
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function exactUsageLines(contexts, g) {
|
|
66
|
+
if (!contexts.length) return []
|
|
67
|
+
return contexts.map((context) => {
|
|
68
|
+
const node = g.byId.get(String(context.symbol || context.definition?.id || ''))
|
|
69
|
+
const label = String(context.definition?.label || node?.label || context.symbol || 'symbol')
|
|
70
|
+
if (context.status !== 'OK') return `Exact usage: ${label} — unavailable (${context.status || 'UNKNOWN'}).`
|
|
71
|
+
const exact = context.evidence?.state === 'EXACT'
|
|
72
|
+
const occurrences = Number(context.references?.occurrences) || 0
|
|
73
|
+
const files = Number(context.references?.files) || 0
|
|
74
|
+
const inbound = Array.isArray(context.inbound?.shown) ? context.inbound.shown.slice(0, 3) : []
|
|
75
|
+
const callers = inbound.map((item) => `${item.label || item.id}${item.file ? ` [${item.file}]` : ''}`).join(', ')
|
|
76
|
+
return `${exact ? 'Exact usage' : 'Bounded usage'}: ${label} — ${occurrences} reference occurrence(s)${files ? ` in ${files} file(s)` : ''}; ${Number(context.inbound?.total) || 0} inbound container(s)${callers ? `: ${callers}` : ''}.`
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
65
80
|
function decide({phase, impact, graph, architecture, duplicates, api, tests, suggestedTests, testCoverageState}) {
|
|
66
81
|
if (phase === 'plan') return tests.state === 'BLOCKED'
|
|
67
82
|
? {verdict: 'BLOCKED', blockers: [`targeted test plan was rejected: ${tests.reason || 'invalid request'}`], unknowns: []}
|
|
@@ -162,6 +177,7 @@ export async function tVerifiedChange(g, args = {}, ctx = {}, tools = {}, permis
|
|
|
162
177
|
`${decision.verdict} — verified_change ${phase}`, `Task: ${String(args.task).slice(0, 500)}`,
|
|
163
178
|
`Change: ${changedFiles.length} file(s), ${impact.seeds?.ids?.length || 0} exact seed(s), blast radius ${impact.blastRadius?.impacted || 0}.`,
|
|
164
179
|
`Edit context: ${retrieval.selected.length} symbol(s); ${contexts.length} exact bundle(s); data-flow ${dataFlow.status} (${dataFlow.edges.length} call edge(s)).`,
|
|
180
|
+
...exactUsageLines(contexts, g),
|
|
165
181
|
`Ratchets: graph ${graph.state}; architecture ${architecture.state}; duplicates ${duplicates.state}; API ${api.state}; tests ${testProof.state}.`,
|
|
166
182
|
...decision.blockers.map((item) => `BLOCKER: ${item}`), ...decision.unknowns.map((item) => `UNKNOWN: ${item}`),
|
|
167
183
|
].join('\n')
|
|
@@ -46,7 +46,7 @@ const DEFAULT_RULES = [
|
|
|
46
46
|
{
|
|
47
47
|
category: "test",
|
|
48
48
|
pattern: "test/spec roots and conventional test filenames",
|
|
49
|
-
regex: /(^|\/)(?:__tests?__|tests?|spec)(\/|$)|\.(?:test|itest|spec)\.[^.\/]+$|_test\.go$|(^|\/)
|
|
49
|
+
regex: /(^|\/)(?:__tests?__|tests?|spec)(\/|$)|\.(?:test|itest|spec)\.[^.\/]+$|_test\.go$|(^|\/)test(?:_[^/]*)?\.py$/i,
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
category: "generated",
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
precisionOverlayMatches,
|
|
8
8
|
precisionSemanticInputsMatch,
|
|
9
9
|
} from './lsp-overlay.js'
|
|
10
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
10
11
|
|
|
11
12
|
export const SYMBOL_PRECISION_CACHE_V = 1
|
|
12
13
|
export const SYMBOL_PRECISION_CACHE_FILE = 'precision-symbols.json'
|
|
@@ -79,6 +80,56 @@ function cacheable(overlay) {
|
|
|
79
80
|
&& overlay?.reason === 'semantic precision stopped at a configured safety limit'
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
// Point-query results intentionally live outside the broad precision overlay. Health tools still
|
|
84
|
+
// need to consume revision-bound positive evidence: once an exact query found a reference, the
|
|
85
|
+
// same symbol must not remain in a dead-code queue. This reader is synchronous and fail-closed;
|
|
86
|
+
// stale, malformed, incomplete no-reference, or semantically changed entries contribute nothing.
|
|
87
|
+
export function readCachedSymbolPrecisionEvidence({repoRoot, graphPath, graph} = {}) {
|
|
88
|
+
const empty = {referenceSymbols: [], productionReferenceSymbols: [], testReferenceSymbols: [], noReferenceSymbols: []}
|
|
89
|
+
if (!repoRoot || !graphPath || !graph) return empty
|
|
90
|
+
const precisionGraph = graph.graphPrecisionMode === 'off' ? {...graph, graphPrecisionMode: 'lsp'} : graph
|
|
91
|
+
const nodesById = new Map((precisionGraph.nodes || []).map((node) => [String(node.id), node]))
|
|
92
|
+
const classifier = createPathClassifier(repoRoot)
|
|
93
|
+
const referenced = new Set()
|
|
94
|
+
const production = new Set()
|
|
95
|
+
const tests = new Set()
|
|
96
|
+
const noReference = new Set()
|
|
97
|
+
const semanticMatches = new Map()
|
|
98
|
+
for (const entry of readCache(symbolPrecisionCachePath(graphPath)).entries) {
|
|
99
|
+
const overlay = entry?.overlay
|
|
100
|
+
if (!overlay || !precisionOverlayMatches(overlay, precisionGraph, {request: overlay.request})) continue
|
|
101
|
+
const fingerprint = String(overlay.semanticInputFingerprint || '')
|
|
102
|
+
if (!semanticMatches.has(fingerprint)) semanticMatches.set(
|
|
103
|
+
fingerprint,
|
|
104
|
+
precisionSemanticInputsMatch(overlay, repoRoot, precisionGraph),
|
|
105
|
+
)
|
|
106
|
+
if (!semanticMatches.get(fingerprint)) continue
|
|
107
|
+
const targetId = String(entry.targetId || '')
|
|
108
|
+
if (!nodesById.has(targetId)) continue
|
|
109
|
+
const locations = Array.isArray(overlay.locations)
|
|
110
|
+
? overlay.locations.filter((location) => String(location?.target || '') === targetId)
|
|
111
|
+
: []
|
|
112
|
+
if (locations.length) {
|
|
113
|
+
referenced.add(targetId)
|
|
114
|
+
for (const location of locations) {
|
|
115
|
+
const file = String(location?.file || nodesById.get(String(location?.source || ''))?.source_file || '')
|
|
116
|
+
if (!file) continue
|
|
117
|
+
const info = classifier.explain(file, {content: ''})
|
|
118
|
+
if (hasPathClass(info, 'test', 'e2e')) tests.add(targetId)
|
|
119
|
+
else production.add(targetId)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (overlay.state === 'COMPLETE' && Array.isArray(overlay.noReferenceSymbols)
|
|
123
|
+
&& overlay.noReferenceSymbols.some((id) => String(id) === targetId)) noReference.add(targetId)
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
referenceSymbols: [...referenced],
|
|
127
|
+
productionReferenceSymbols: [...production],
|
|
128
|
+
testReferenceSymbols: [...tests],
|
|
129
|
+
noReferenceSymbols: [...noReference],
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
82
133
|
export async function querySymbolPrecision({
|
|
83
134
|
repoRoot,
|
|
84
135
|
graphPath,
|
package/docs/releases/v0.2.7.md
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
# Weavatrix 0.2.7
|
|
2
|
-
|
|
3
|
-
## Proof-carrying changes
|
|
4
|
-
|
|
5
|
-
`verified_change` is a new high-level MCP workflow for planning and verifying a code change. It
|
|
6
|
-
accepts a natural-language task, an optional unified diff or changed-file hints, and an immutable Git
|
|
7
|
-
baseline. Its stable result is one of:
|
|
8
|
-
|
|
9
|
-
- `PASS`: every applicable enabled proof completed and no ratchet failed;
|
|
10
|
-
- `BLOCKED`: a test failed, a new duplicate intersects changed code, a runtime cycle appeared, an
|
|
11
|
-
architecture ratchet failed, or bounded API evidence found an HTTP method mismatch;
|
|
12
|
-
- `UNKNOWN`: required evidence is missing, partial, disabled, or not configured.
|
|
13
|
-
|
|
14
|
-
The structured result contains intent-expanded task retrieval combined with exact changed-symbol
|
|
15
|
-
seeds, up to five compact LSP/graph edit contexts, symbol-aware blast radius, affected-test paths,
|
|
16
|
-
bounded JS/TS call-argument-to-parameter evidence, Git graph drift, architecture verification, a
|
|
17
|
-
new-duplicate baseline comparison, and optional cross-repository HTTP contract evidence.
|
|
18
|
-
|
|
19
|
-
The workflow respects the existing capability boundary. Exact source/LSP context requires `source`,
|
|
20
|
-
duplicate comparison and architecture verification require `health`, and API tracing requires
|
|
21
|
-
`crossrepo`. A narrow custom profile is never silently bypassed.
|
|
22
|
-
|
|
23
|
-
## Safe targeted tests
|
|
24
|
-
|
|
25
|
-
The workflow does not accept shell commands. It can execute at most five existing `package.json`
|
|
26
|
-
scripts whose names match the test/check/verify allowlist. Execution requires both
|
|
27
|
-
`run_tests:true` on the tool call and `WEAVATRIX_ALLOW_TEST_RUNS=1` in the server environment. The
|
|
28
|
-
default remains read-only and returns the test plan or `UNKNOWN` when runtime evidence was requested
|
|
29
|
-
but not authorized.
|
|
30
|
-
|
|
31
|
-
## Malware scanner correction
|
|
32
|
-
|
|
33
|
-
The npm content sweep now starts at actual installed package roots instead of the entire
|
|
34
|
-
`node_modules` container. Hidden package-manager caches and hosted release snapshots are therefore
|
|
35
|
-
not attributed as dependencies and cannot make Weavatrix flag its own malware-signature source.
|
|
36
|
-
|
|
37
|
-
Heuristic remediation no longer says to treat a package as compromised or rotate all reachable
|
|
38
|
-
secrets. It asks the operator to quarantine and inspect the package, verify origin and lockfile
|
|
39
|
-
integrity, and rotate secrets only after execution or credential exposure is confirmed.
|
|
40
|
-
|
|
41
|
-
Static heuristic findings now have a hard `high` severity ceiling. They expose
|
|
42
|
-
`confirmedExecution:false` plus explicit runtime/origin/lockfile/exposure verification states;
|
|
43
|
-
`critical` is reserved for independently confirmed malicious advisories or runtime evidence.
|
|
44
|
-
|
|
45
|
-
Package-lock install-script drift now compares only `preinstall`, `install`, and `postinstall`.
|
|
46
|
-
Development/publication hooks such as `prepare` no longer create false high-severity drift findings.
|
|
47
|
-
|
|
48
|
-
## Signal-quality corrections
|
|
49
|
-
|
|
50
|
-
- `query_graph` applies production-first path classification to the traversal, not only seed
|
|
51
|
-
selection. Tests, generated code, fixtures, docs, benchmarks and excluded paths require an
|
|
52
|
-
explicit question class or `include_classified:true`. Exact non-product seed files remain usable.
|
|
53
|
-
- `verified_change` task retrieval uses the same production-first policy while always retaining
|
|
54
|
-
exact changed symbols. The bundled routing microbenchmark moved from one test-symbol false
|
|
55
|
-
positive (`0.25`) to zero (`0`) without losing any of its three expected production targets.
|
|
56
|
-
- Unreferenced constant/field leaves that do not match the query are hidden by default; use
|
|
57
|
-
`include_low_signal:true` for diagnostic expansion.
|
|
58
|
-
- `hot_path_review` now defaults to a focused score gate of 85 plus a narrow strong-local fallback.
|
|
59
|
-
On the 0.2.7 source tree this reduced the default queue from 717 diagnostic candidates to 116 of
|
|
60
|
-
1,043 analyzed symbols; `min_score:0` restores the full diagnostic set.
|
|
61
|
-
- `find_dead_code` adds `STRONG_STATIC_EVIDENCE`, `BOUNDED_STATIC_EVIDENCE`, and
|
|
62
|
-
`HIGH_UNCERTAINTY` tiers, structured completed/remaining checks, and `autoDelete:false` on every
|
|
63
|
-
candidate. Strong static evidence means exact zero LSP references, not permission to delete.
|
|
64
|
-
- npm unused/missing/duplicate dependency findings now carry manifest section, indexed-source,
|
|
65
|
-
script/config, package-scope and unresolved dynamic/plugin verification state. Unused dependencies
|
|
66
|
-
remain `REVIEW_REQUIRED`; missing dependencies retain the exact importing-file evidence.
|
|
67
|
-
|
|
68
|
-
## Benchmark contract
|
|
69
|
-
|
|
70
|
-
`npm run benchmark:agent` runs a deterministic task-to-symbol routing microbenchmark and reports task
|
|
71
|
-
success, false-positive rate, estimated output tokens, and duration. This is intentionally labelled
|
|
72
|
-
as a routing microbenchmark, not an autonomous end-to-end change benchmark.
|
|
73
|
-
|
|
74
|
-
Independent Codebase Memory and Serena results may be supplied with
|
|
75
|
-
`--independent-results <json>` using the blind evaluator contract in
|
|
76
|
-
[`docs/agent-task-benchmark.md`](../agent-task-benchmark.md). Without same-task results for all three
|
|
77
|
-
systems, comparison status is `INCOMPLETE`; `npm run benchmark:agent:release` fails closed. The
|
|
78
|
-
repository does not manufacture competitor scores.
|
|
79
|
-
|
|
80
|
-
## Scope and limitations
|
|
81
|
-
|
|
82
|
-
- The interprocedural evidence maps call arguments to declared callee parameters for bounded JS/TS
|
|
83
|
-
graph call edges. It is not a CFG, value-propagation engine, or taint analysis.
|
|
84
|
-
- An absent architecture contract produces `UNKNOWN`; provisional budgets are guidance, not policy.
|
|
85
|
-
- A static path to a test is not proof that the test executed. A `PASS` that depends on runtime
|
|
86
|
-
behavior requires explicitly authorized test execution.
|
|
87
|
-
- API proof is optional and registry-scoped. No client match is `UNKNOWN`, never proof of no external
|
|
88
|
-
consumers.
|
|
89
|
-
|
|
90
|
-
## Tool catalog
|
|
91
|
-
|
|
92
|
-
0.2.7 exposes 36 MCP tools. The default offline profile exposes 33; pinned exposes 30. Network tools
|
|
93
|
-
remain absent from the offline and pinned profiles.
|