weavatrix 0.2.7 → 0.2.9
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 +101 -27
- package/SECURITY.md +30 -7
- package/docs/releases/v0.2.9.md +123 -0
- package/package.json +3 -3
- package/skill/SKILL.md +12 -7
- package/src/analysis/dead-check.js +24 -2
- package/src/analysis/dep-check-ecosystems.js +41 -2
- package/src/analysis/duplicate-groups.js +11 -2
- package/src/analysis/endpoints.js +256 -22
- package/src/analysis/internal-audit.collect.js +53 -20
- package/src/graph/builder/lang-rust.js +49 -12
- package/src/mcp/catalog.mjs +13 -10
- package/src/mcp/evidence-snapshot.package-graph.mjs +257 -3
- package/src/mcp/graph-context.mjs +72 -2
- package/src/mcp/tools-actions.mjs +170 -36
- package/src/mcp/tools-context.mjs +25 -8
- package/src/mcp/tools-endpoints.mjs +162 -0
- package/src/mcp/tools-graph.mjs +1 -1
- package/src/mcp/tools-health.mjs +92 -42
- package/src/mcp/tools-source.mjs +3 -3
- package/src/mcp/tools-verified-change.mjs +16 -0
- package/src/mcp-server.mjs +30 -2
- package/src/mcp-source-tools.mjs +10 -7
- package/src/path-classification.js +1 -1
- package/src/precision/symbol-query.js +51 -0
- package/docs/releases/v0.2.7.md +0 -93
|
@@ -37,6 +37,13 @@ function groupPairs(data, settings) {
|
|
|
37
37
|
}).sort((a, b) => b.tokens - a.tokens)
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export function isFrameworkBoilerplateCloneGroup(group) {
|
|
41
|
+
const members = Array.isArray(group?.members) ? group.members : []
|
|
42
|
+
if (members.length < 2) return false
|
|
43
|
+
return members.every((member) => /(?:^|\/)\w[^/]*\.router\.[cm]?[jt]s$/i.test(String(member.file || '').replace(/\\/g, '/'))
|
|
44
|
+
&& /^(?:router|routes)\(?\)?$/i.test(String(member.label || '').trim()))
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
export function analyzeDuplicateGroups(repoRoot, graphPath, args = {}) {
|
|
41
48
|
const settings = {
|
|
42
49
|
simMin: Math.min(100, Math.max(50, Number(args.min_similarity) || 80)),
|
|
@@ -44,11 +51,13 @@ export function analyzeDuplicateGroups(repoRoot, graphPath, args = {}) {
|
|
|
44
51
|
mode: args.mode === 'strict' ? 'strict' : 'renamed',
|
|
45
52
|
skipTests: args.include_tests !== true,
|
|
46
53
|
includeClassified: args.include_classified === true || args.include_non_product === true,
|
|
54
|
+
includeBoilerplate: args.include_boilerplate === true,
|
|
47
55
|
}
|
|
48
56
|
const data = computeDuplicates(repoRoot, graphPath, {includeStrings: args.include_strings === true, minTokens: settings.tokMin})
|
|
49
|
-
const
|
|
57
|
+
const allGroups = groupPairs(data, settings)
|
|
58
|
+
const groups = settings.includeBoilerplate ? allGroups : allGroups.filter((group) => !isFrameworkBoilerplateCloneGroup(group))
|
|
50
59
|
const suppressed = (data.frags || []).filter((fragment) => !eligible(fragment, settings)).length
|
|
51
|
-
return {settings, groups, suppressed}
|
|
60
|
+
return {settings, groups, suppressed, boilerplateSuppressed: allGroups.length - groups.length}
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
const digest = (value) => createHash('sha256').update(value).digest('hex').slice(0, 20)
|
|
@@ -11,8 +11,10 @@ import { safeRead } from "../util.js";
|
|
|
11
11
|
import { createRepoBoundary } from "../repo-path.js";
|
|
12
12
|
import { extractRustEndpoints } from "./endpoints-rust.js";
|
|
13
13
|
import { extractSpringEndpoints } from "./endpoints-java.js";
|
|
14
|
+
import { posix } from "node:path";
|
|
14
15
|
|
|
15
16
|
const MAX_FILES = 3000;
|
|
17
|
+
const MAX_ENDPOINTS = 2000;
|
|
16
18
|
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT", "ALL", "ANY"]);
|
|
17
19
|
// UNAMBIGUOUS HTTP CLIENTS (make requests) vs servers (define routes) — reject `axios.get("/x")`-style client
|
|
18
20
|
// calls in frontend code. Ambiguous names (api/client/service — could be a server router) are NOT listed;
|
|
@@ -27,6 +29,38 @@ function lineAt(text, index) {
|
|
|
27
29
|
return line;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
// Regex extractors must never see commented-out routes. Preserve string literals and every source
|
|
33
|
+
// offset, but replace comment bodies with spaces so endpoint line numbers still refer to the original
|
|
34
|
+
// file. Python `#` comments are enabled only for .py files; Rust attributes such as #[get] stay intact.
|
|
35
|
+
function maskComments(text, { hashComments = false } = {}) {
|
|
36
|
+
const chars = String(text || "").split("");
|
|
37
|
+
let quote = "", escaped = false, lineComment = false, blockComment = false;
|
|
38
|
+
for (let i = 0; i < chars.length; i++) {
|
|
39
|
+
const ch = chars[i], next = chars[i + 1];
|
|
40
|
+
if (lineComment) {
|
|
41
|
+
if (ch === "\n" || ch === "\r") lineComment = false;
|
|
42
|
+
else chars[i] = " ";
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (blockComment) {
|
|
46
|
+
if (ch === "*" && next === "/") { chars[i] = chars[i + 1] = " "; i++; blockComment = false; }
|
|
47
|
+
else if (ch !== "\n" && ch !== "\r") chars[i] = " ";
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (quote) {
|
|
51
|
+
if (escaped) escaped = false;
|
|
52
|
+
else if (ch === "\\") escaped = true;
|
|
53
|
+
else if (ch === quote) quote = "";
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (ch === '"' || ch === "'" || ch === "`") { quote = ch; continue; }
|
|
57
|
+
if (ch === "/" && next === "/") { chars[i] = chars[i + 1] = " "; i++; lineComment = true; continue; }
|
|
58
|
+
if (ch === "/" && next === "*") { chars[i] = chars[i + 1] = " "; i++; blockComment = true; continue; }
|
|
59
|
+
if (hashComments && ch === "#") { chars[i] = " "; lineComment = true; }
|
|
60
|
+
}
|
|
61
|
+
return chars.join("");
|
|
62
|
+
}
|
|
63
|
+
|
|
30
64
|
// best-effort bare handler name from a value expression: the LAST identifier, unwrapping wrappers like
|
|
31
65
|
// executionRoute(queryHandlers.executeQuery) → executeQuery, asyncHandler(fn) → fn, a.b.c → c.
|
|
32
66
|
// An INLINE handler (arrow / function literal) has no named method to join to, so it returns "" (the
|
|
@@ -52,6 +86,152 @@ const OPENAPI_BLOCK = /\boperationId\b|\bresponses\s*:|\brequestBody\b|\bschemaR
|
|
|
52
86
|
const looksLikePath = (p) => typeof p === "string" && /^\/[\w\-./:{}*$?]*$/.test(p) && !p.includes("://");
|
|
53
87
|
const cleanPath = (p) => String(p || "").replace(/\/+$/, "") || "/";
|
|
54
88
|
|
|
89
|
+
const JS_ROUTE_EXTENSIONS = [".js", ".ts", ".tsx", ".jsx", ".cjs", ".mjs"];
|
|
90
|
+
const normalizedFile = (file) => String(file || "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
91
|
+
|
|
92
|
+
function importBindings(text) {
|
|
93
|
+
const bindings = new Map();
|
|
94
|
+
const add = (name, specifier) => {
|
|
95
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name || "") && typeof specifier === "string") bindings.set(name, specifier);
|
|
96
|
+
};
|
|
97
|
+
let match;
|
|
98
|
+
const direct = /\bimport\s+([A-Za-z_$][\w$]*)\s*(?:,\s*\{[^}]*\})?\s+from\s*(["'`])([^"'`]+)\2/g;
|
|
99
|
+
while ((match = direct.exec(text))) add(match[1], match[3]);
|
|
100
|
+
const namespace = /\bimport\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s*(["'`])([^"'`]+)\2/g;
|
|
101
|
+
while ((match = namespace.exec(text))) add(match[1], match[3]);
|
|
102
|
+
const named = /\bimport\s*\{([^}]+)\}\s*from\s*(["'`])([^"'`]+)\2/g;
|
|
103
|
+
while ((match = named.exec(text))) {
|
|
104
|
+
for (const item of match[1].split(",")) {
|
|
105
|
+
const binding = /^\s*[A-Za-z_$][\w$]*(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*$/.exec(item);
|
|
106
|
+
if (binding) add(binding[1] || item.trim(), match[3]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const commonJs = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*(["'`])([^"'`]+)\2\s*\)/g;
|
|
110
|
+
while ((match = commonJs.exec(text))) add(match[1], match[3]);
|
|
111
|
+
return bindings;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function handlerReference(expr) {
|
|
115
|
+
const s = String(expr || "").trim();
|
|
116
|
+
if (!s || /=>/.test(s) || /^\s*(async\s+)?function\b/.test(s)) return "";
|
|
117
|
+
const refs = [...s.matchAll(/([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)+)/g)];
|
|
118
|
+
return refs.length ? refs.at(-1)[1].replace(/\s+/g, "") : "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolveImportedFile(importer, specifier, availableFiles) {
|
|
122
|
+
if (!specifier?.startsWith(".")) return null;
|
|
123
|
+
const base = posix.normalize(posix.join(posix.dirname(importer), specifier));
|
|
124
|
+
const candidates = [base];
|
|
125
|
+
if (!JS_ROUTE_EXTENSIONS.some((extension) => base.endsWith(extension))) {
|
|
126
|
+
for (const extension of JS_ROUTE_EXTENSIONS) candidates.push(`${base}${extension}`);
|
|
127
|
+
for (const extension of JS_ROUTE_EXTENSIONS) candidates.push(`${base}/index${extension}`);
|
|
128
|
+
}
|
|
129
|
+
return candidates.find((candidate) => availableFiles.has(candidate)) || null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function callArguments(text, openParen) {
|
|
133
|
+
let quote = "", escaped = false, depth = 0, start = openParen + 1;
|
|
134
|
+
const args = [];
|
|
135
|
+
for (let index = openParen; index < text.length; index++) {
|
|
136
|
+
const char = text[index];
|
|
137
|
+
if (quote) {
|
|
138
|
+
if (escaped) escaped = false;
|
|
139
|
+
else if (char === "\\") escaped = true;
|
|
140
|
+
else if (char === quote) quote = "";
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (char === '"' || char === "'" || char === "`") { quote = char; continue; }
|
|
144
|
+
if (char === "(") { depth++; continue; }
|
|
145
|
+
if (char === ")") {
|
|
146
|
+
depth--;
|
|
147
|
+
if (depth === 0) {
|
|
148
|
+
args.push(text.slice(start, index).trim());
|
|
149
|
+
return {args, end: index + 1};
|
|
150
|
+
}
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (char === "," && depth === 1) {
|
|
154
|
+
args.push(text.slice(start, index).trim());
|
|
155
|
+
start = index + 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function routerMounts(text, file, availableFiles) {
|
|
162
|
+
const scanText = maskComments(text);
|
|
163
|
+
const bindings = importBindings(scanText);
|
|
164
|
+
const mounts = [];
|
|
165
|
+
const useCall = /\b[A-Za-z_$][\w$]*\s*\.\s*use\s*\(/g;
|
|
166
|
+
let match;
|
|
167
|
+
while ((match = useCall.exec(scanText))) {
|
|
168
|
+
const openParen = scanText.indexOf("(", match.index);
|
|
169
|
+
const parsed = callArguments(scanText, openParen);
|
|
170
|
+
if (!parsed) continue;
|
|
171
|
+
useCall.lastIndex = parsed.end;
|
|
172
|
+
const args = parsed.args.filter(Boolean);
|
|
173
|
+
if (!args.length) continue;
|
|
174
|
+
const literal = /^(["'`])(\/[^"'`]*)\1$/.exec(args[0]);
|
|
175
|
+
const mountPath = literal ? cleanPath(literal[2]) : "/";
|
|
176
|
+
const childExpression = args.at(-1) || "";
|
|
177
|
+
const identifier = /^([A-Za-z_$][\w$]*)(?:\.[A-Za-z_$][\w$]*)?$/.exec(childExpression)?.[1];
|
|
178
|
+
const specifier = identifier ? bindings.get(identifier) : null;
|
|
179
|
+
const child = resolveImportedFile(file, specifier, availableFiles);
|
|
180
|
+
if (child && child !== file) mounts.push({parent: file, child, path: mountPath, line: lineAt(text, match.index)});
|
|
181
|
+
}
|
|
182
|
+
return mounts;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function joinEndpointPath(base, route) {
|
|
186
|
+
const left = cleanPath(base || "/");
|
|
187
|
+
const right = cleanPath(route || "/");
|
|
188
|
+
if (left === "/") return right;
|
|
189
|
+
if (right === "/") return left;
|
|
190
|
+
return cleanPath(`${left}/${right.replace(/^\/+/, "")}`.replace(/\/{2,}/g, "/"));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function mountedBasePaths(files, sources) {
|
|
194
|
+
const available = new Set(files);
|
|
195
|
+
const incoming = new Map();
|
|
196
|
+
const mounts = [];
|
|
197
|
+
for (const file of files) {
|
|
198
|
+
for (const mount of routerMounts(sources.get(file) || "", file, available)) {
|
|
199
|
+
mounts.push(mount);
|
|
200
|
+
if (!incoming.has(mount.child)) incoming.set(mount.child, []);
|
|
201
|
+
incoming.get(mount.child).push(mount);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const cache = new Map();
|
|
205
|
+
const resolve = (file, stack = new Set()) => {
|
|
206
|
+
if (cache.has(file)) return cache.get(file);
|
|
207
|
+
if (stack.has(file)) return [];
|
|
208
|
+
const parents = incoming.get(file) || [];
|
|
209
|
+
if (!parents.length) return [{path: "", chain: []}];
|
|
210
|
+
const nextStack = new Set(stack).add(file);
|
|
211
|
+
const paths = [];
|
|
212
|
+
for (const mount of parents) {
|
|
213
|
+
for (const base of resolve(mount.parent, nextStack)) {
|
|
214
|
+
const composed = joinEndpointPath(base.path, mount.path);
|
|
215
|
+
const key = `${composed}\0${base.chain.map((item) => `${item.file}:${item.line}:${item.path}`).join("|")}\0${mount.parent}:${mount.line}:${mount.path}`;
|
|
216
|
+
if (!paths.some((item) => item.key === key)) paths.push({
|
|
217
|
+
key,
|
|
218
|
+
path: composed,
|
|
219
|
+
chain: [...base.chain, {file: mount.parent, line: mount.line, path: mount.path, child: mount.child}],
|
|
220
|
+
});
|
|
221
|
+
if (paths.length >= 32) break;
|
|
222
|
+
}
|
|
223
|
+
if (paths.length >= 32) break;
|
|
224
|
+
}
|
|
225
|
+
const result = paths.length ? paths.map(({key, ...item}) => item) : [{path: "", chain: []}];
|
|
226
|
+
cache.set(file, result);
|
|
227
|
+
return result;
|
|
228
|
+
};
|
|
229
|
+
return {
|
|
230
|
+
paths: new Map(files.map((file) => [file, resolve(file)])),
|
|
231
|
+
mounts,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
55
235
|
export function nextRoutePath(file) {
|
|
56
236
|
const parts = String(file || "").replace(/\\/g, "/").split("/").filter(Boolean);
|
|
57
237
|
if (!/^route\.[cm]?[jt]s$/i.test(parts.at(-1) || "")) return "";
|
|
@@ -75,12 +255,15 @@ export function extractEndpointsFromText(text, file) {
|
|
|
75
255
|
const py = /\.py$/i.test(file);
|
|
76
256
|
const rust = /\.rs$/i.test(file);
|
|
77
257
|
const java = /\.java$/i.test(file);
|
|
258
|
+
const scanText = maskComments(text, { hashComments: py });
|
|
78
259
|
const add = (method, path, expr, idx) => {
|
|
79
260
|
const p = cleanPath(path);
|
|
80
261
|
if (!looksLikePath(p)) return;
|
|
81
262
|
const m = String(method || "ANY").toUpperCase();
|
|
82
263
|
if (!HTTP_METHODS.has(m)) return;
|
|
83
|
-
|
|
264
|
+
const handler = handlerName(expr);
|
|
265
|
+
const handlerRef = handlerReference(expr);
|
|
266
|
+
out.push({ method: m, path: p, handler, ...(handlerRef ? {handlerRef} : {}), file, line: lineAt(text, idx) });
|
|
84
267
|
};
|
|
85
268
|
|
|
86
269
|
// Next.js App Router: the filesystem provides the path and exported HTTP-method functions provide the
|
|
@@ -90,13 +273,13 @@ export function extractEndpointsFromText(text, file) {
|
|
|
90
273
|
const seen = new Set();
|
|
91
274
|
const direct = /\bexport\s+(?:(?:async|declare)\s+)*(?:function\s+|(?:const|let|var)\s+)(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/g;
|
|
92
275
|
let nm;
|
|
93
|
-
while ((nm = direct.exec(
|
|
276
|
+
while ((nm = direct.exec(scanText))) {
|
|
94
277
|
const method = nm[1].toUpperCase();
|
|
95
278
|
if (!seen.has(method)) { seen.add(method); add(method, nextPath, method, nm.index); }
|
|
96
279
|
}
|
|
97
280
|
const lists = /\bexport\s*\{([^}]+)\}/g;
|
|
98
281
|
let lm;
|
|
99
|
-
while ((lm = lists.exec(
|
|
282
|
+
while ((lm = lists.exec(scanText))) {
|
|
100
283
|
for (const item of lm[1].split(",")) {
|
|
101
284
|
const mm = /^\s*([A-Za-z_$][\w$]*)(?:\s+as\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS))?\s*$/.exec(item);
|
|
102
285
|
if (!mm) continue;
|
|
@@ -108,9 +291,9 @@ export function extractEndpointsFromText(text, file) {
|
|
|
108
291
|
}
|
|
109
292
|
}
|
|
110
293
|
|
|
111
|
-
if (rust) extractRustEndpoints(
|
|
294
|
+
if (rust) extractRustEndpoints(scanText, add);
|
|
112
295
|
if (java) {
|
|
113
|
-
out.push(...extractSpringEndpoints(
|
|
296
|
+
out.push(...extractSpringEndpoints(scanText, file));
|
|
114
297
|
return out; // generic JS-style method calls would turn Java HTTP clients into fake server routes
|
|
115
298
|
}
|
|
116
299
|
|
|
@@ -118,14 +301,14 @@ export function extractEndpointsFromText(text, file) {
|
|
|
118
301
|
// find each "…": { or "…": expr, where the key looks like a path
|
|
119
302
|
const objKeyRe = /(["'`])(\/[^"'`]*)\1\s*:\s*(\{)?/g;
|
|
120
303
|
let m;
|
|
121
|
-
while ((m = objKeyRe.exec(
|
|
304
|
+
while ((m = objKeyRe.exec(scanText))) {
|
|
122
305
|
const path = m[2], keyIdx = m.index;
|
|
123
306
|
if (m[3]) {
|
|
124
307
|
// object of METHOD: handler — scan to the matching close brace (routes objects are shallow)
|
|
125
308
|
let i = objKeyRe.lastIndex, depth = 1;
|
|
126
309
|
const start = i;
|
|
127
|
-
while (i <
|
|
128
|
-
const body =
|
|
310
|
+
while (i < scanText.length && depth > 0) { const c = scanText[i]; if (c === "{") depth++; else if (c === "}") depth--; i++; }
|
|
311
|
+
const body = scanText.slice(start, i - 1);
|
|
129
312
|
objKeyRe.lastIndex = i;
|
|
130
313
|
if (OPENAPI_BLOCK.test(body)) continue; // documentation, not a route table
|
|
131
314
|
const methodRe = /\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b\s*:\s*([^,\n}]+)/gi;
|
|
@@ -133,9 +316,10 @@ export function extractEndpointsFromText(text, file) {
|
|
|
133
316
|
while ((mm = methodRe.exec(body))) add(mm[1], path, mm[2], keyIdx);
|
|
134
317
|
} else {
|
|
135
318
|
// "/path": handlerExpr — a direct handler (any method); grab up to the next , or }
|
|
136
|
-
const tail =
|
|
319
|
+
const tail = scanText.slice(objKeyRe.lastIndex, objKeyRe.lastIndex + 200);
|
|
137
320
|
const em = /^([^,\n}]+)/.exec(tail);
|
|
138
|
-
|
|
321
|
+
// A string/number/array value is an ordinary path/name lookup, not an executable handler.
|
|
322
|
+
if (em && !/^\s*(?:\{|["'`\[]|[-+]?\d|true\b|false\b|null\b|undefined\b)/i.test(em[1])) add("ANY", path, em[1], keyIdx);
|
|
139
323
|
}
|
|
140
324
|
}
|
|
141
325
|
|
|
@@ -146,7 +330,7 @@ export function extractEndpointsFromText(text, file) {
|
|
|
146
330
|
// FRONTEND code, not server routes. A server route also REQUIRES a handler arg (an identifier/function),
|
|
147
331
|
// so a bare `client.get("/x")` or one whose 2nd arg is a config object literal `{…}` is skipped.
|
|
148
332
|
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(
|
|
333
|
+
while ((m = callRe.exec(scanText))) {
|
|
150
334
|
const caller = m[1], arg2 = String(m[5] || "").trim();
|
|
151
335
|
if (HTTP_CLIENT_CALLER.test(caller)) continue; // axios/http/fetch/apiClient… → a client request
|
|
152
336
|
if (!arg2 || arg2[0] === "{") continue; // no handler, or a config object → not a route def
|
|
@@ -155,14 +339,14 @@ export function extractEndpointsFromText(text, file) {
|
|
|
155
339
|
|
|
156
340
|
// ---- Go net/http: mux.HandleFunc("/path", handler) / http.Handle("/path", h) ------------------
|
|
157
341
|
const goRe = /\.\s*(?:HandleFunc|Handle)\s*\(\s*(["'`])(\/[^"'`]*)\1\s*,\s*([\s\S]{0,120}?)\)/g;
|
|
158
|
-
while ((m = goRe.exec(
|
|
342
|
+
while ((m = goRe.exec(scanText))) add("ANY", m[2], m[3], m.index);
|
|
159
343
|
|
|
160
344
|
// ---- decorators: @app.get("/path") / @router.post("/path") / @Get("/path") -------------------
|
|
161
345
|
if (py || /\.(ts|js|tsx|jsx|cjs|mjs)$/i.test(file)) {
|
|
162
346
|
const decoRe = /@[\w$]*\.?\s*(get|post|put|patch|delete|head|options)\s*\(\s*(["'`])(\/[^"'`]*)\2/gi;
|
|
163
|
-
while ((m = decoRe.exec(
|
|
347
|
+
while ((m = decoRe.exec(scanText))) {
|
|
164
348
|
// the handler is the def/function on a following line — best-effort: next def name
|
|
165
|
-
const after =
|
|
349
|
+
const after = scanText.slice(decoRe.lastIndex, decoRe.lastIndex + 200);
|
|
166
350
|
const fn = /\b(?:def|async\s+def|function|const|export\s+function)\s+([A-Za-z_$][\w$]*)/.exec(after);
|
|
167
351
|
add(m[1], m[3], fn ? fn[1] : "", m.index);
|
|
168
352
|
}
|
|
@@ -178,26 +362,76 @@ const normParamKey = (p) => String(p).replace(/\{([^/}]+)\}/g, ":$1");
|
|
|
178
362
|
// Detect endpoints across the repo's code files (from the graph's file nodes, or a caller-supplied list
|
|
179
363
|
// of {path, full}). Deduped by method+normalized-path; on a collision the entry with a resolvable
|
|
180
364
|
// handler (and `:param` display) wins. Capped. Returns [{method, path, handler, file, line}].
|
|
181
|
-
export function
|
|
365
|
+
export function analyzeEndpointInventory(repoPath, codeFiles) {
|
|
182
366
|
const files = (codeFiles || []).slice(0, MAX_FILES);
|
|
183
367
|
const byKey = new Map();
|
|
368
|
+
const declarations = new Map();
|
|
184
369
|
const boundary = createRepoBoundary(repoPath);
|
|
370
|
+
const sources = new Map();
|
|
371
|
+
const eligibleFiles = [];
|
|
185
372
|
for (const f of files) {
|
|
186
|
-
const rel = f.path || f;
|
|
373
|
+
const rel = normalizedFile(f.path || f);
|
|
187
374
|
if (!/\.(js|ts|tsx|jsx|cjs|mjs|py|go|rs|java)$/i.test(rel)) continue;
|
|
188
375
|
const resolved = boundary.resolve(rel);
|
|
189
376
|
if (!resolved.ok) continue;
|
|
190
377
|
const text = safeRead(resolved.path);
|
|
378
|
+
sources.set(rel, text || "");
|
|
379
|
+
eligibleFiles.push(rel);
|
|
380
|
+
}
|
|
381
|
+
const mountAnalysis = mountedBasePaths(eligibleFiles, sources);
|
|
382
|
+
let truncated = false;
|
|
383
|
+
for (const rel of eligibleFiles) {
|
|
384
|
+
const text = sources.get(rel);
|
|
191
385
|
if (!text || (!nextRoutePath(rel) && !/["'`]\/|\.(get|post|put|patch|delete)\s*\(|HandleFunc|@\w*\.?(get|post|put|patch|delete)|@(?:[\w$]+\.)*(?:Request|Get|Post|Put|Patch|Delete)Mapping\b/i.test(text))) continue;
|
|
192
386
|
for (const e of extractEndpointsFromText(text, rel.replace(/\\/g, "/"))) {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
387
|
+
const declarationKey = `${e.file}\0${e.line}\0${e.method}\0${normParamKey(e.path)}`;
|
|
388
|
+
if (!declarations.has(declarationKey)) declarations.set(declarationKey, e);
|
|
389
|
+
const bases = mountAnalysis.paths.get(rel) || [{path: "", chain: []}];
|
|
390
|
+
for (const base of bases) {
|
|
391
|
+
const composed = base.path ? joinEndpointPath(base.path, e.path) : e.path;
|
|
392
|
+
const endpoint = {
|
|
393
|
+
...e,
|
|
394
|
+
declaredPath: e.path,
|
|
395
|
+
path: composed,
|
|
396
|
+
mountState: base.chain.length ? "COMPOSED_STATIC" : "DECLARED_LOCAL",
|
|
397
|
+
confidence: base.chain.length ? "high" : "medium",
|
|
398
|
+
mountChain: base.chain,
|
|
399
|
+
...(base.path ? {localPath: e.path} : {}),
|
|
400
|
+
};
|
|
401
|
+
const key = `${endpoint.method} ${normParamKey(endpoint.path)}`;
|
|
402
|
+
const prev = byKey.get(key);
|
|
403
|
+
if (!prev) { byKey.set(key, endpoint); }
|
|
404
|
+
else if (preferEndpoint(endpoint, prev)) { byKey.set(key, endpoint); }
|
|
405
|
+
if (byKey.size >= MAX_ENDPOINTS) { truncated = true; break; }
|
|
406
|
+
}
|
|
407
|
+
if (truncated) break;
|
|
198
408
|
}
|
|
409
|
+
if (truncated) break;
|
|
199
410
|
}
|
|
200
|
-
|
|
411
|
+
const endpoints = [...byKey.values()].sort(sortEndpoints);
|
|
412
|
+
const composed = endpoints.filter((endpoint) => endpoint.mountChain.length).length;
|
|
413
|
+
return {
|
|
414
|
+
endpoints,
|
|
415
|
+
declarations: [...declarations.values()].sort(sortEndpoints),
|
|
416
|
+
mounts: mountAnalysis.mounts,
|
|
417
|
+
stats: {
|
|
418
|
+
scannedFiles: eligibleFiles.length,
|
|
419
|
+
declaredRoutes: declarations.size,
|
|
420
|
+
emittedRoutes: endpoints.length,
|
|
421
|
+
reachableRoutes: composed,
|
|
422
|
+
reachableStaticRoutes: composed,
|
|
423
|
+
composedRoutes: composed,
|
|
424
|
+
localRoutes: endpoints.length - composed,
|
|
425
|
+
localDeclarations: endpoints.length - composed,
|
|
426
|
+
staticMounts: mountAnalysis.mounts.length,
|
|
427
|
+
truncated,
|
|
428
|
+
maxEndpoints: MAX_ENDPOINTS,
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function detectEndpoints(repoPath, codeFiles) {
|
|
434
|
+
return analyzeEndpointInventory(repoPath, codeFiles).endpoints;
|
|
201
435
|
}
|
|
202
436
|
|
|
203
437
|
// true when candidate `a` is a better representative of a route than the already-kept `b`:
|
|
@@ -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
|