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.
@@ -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 groups = groupPairs(data, settings)
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
- out.push({ method: m, path: p, handler: handlerName(expr), file, line: lineAt(text, idx) });
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(text))) {
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(text))) {
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(text, add);
294
+ if (rust) extractRustEndpoints(scanText, add);
112
295
  if (java) {
113
- out.push(...extractSpringEndpoints(text, file));
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(text))) {
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 < text.length && depth > 0) { const c = text[i]; if (c === "{") depth++; else if (c === "}") depth--; i++; }
128
- const body = text.slice(start, i - 1);
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 = text.slice(objKeyRe.lastIndex, objKeyRe.lastIndex + 200);
319
+ const tail = scanText.slice(objKeyRe.lastIndex, objKeyRe.lastIndex + 200);
137
320
  const em = /^([^,\n}]+)/.exec(tail);
138
- if (em && !/^\s*\{/.test(em[1])) add("ANY", path, em[1], keyIdx);
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(text))) {
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(text))) add("ANY", m[2], m[3], m.index);
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(text))) {
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 = text.slice(decoRe.lastIndex, decoRe.lastIndex + 200);
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 detectEndpoints(repoPath, codeFiles) {
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 key = `${e.method} ${normParamKey(e.path)}`;
194
- const prev = byKey.get(key);
195
- if (!prev) { byKey.set(key, e); }
196
- else if (preferEndpoint(e, prev)) { byKey.set(key, e); }
197
- if (byKey.size >= 500) return [...byKey.values()].sort(sortEndpoints);
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
- return [...byKey.values()].sort(sortEndpoints);
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: root requirements*.txt/.in + requirements/ dir + pyproject.toml + Pipfile.
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
- try { names = readdirSync(boundary.root).filter((n) => /^requirements[\w.-]*\.(txt|in)$/i.test(n)); } catch { /* unreadable root */ }
265
- try {
266
- const requirements = boundary.resolve("requirements");
267
- if (requirements.ok) names.push(...readdirSync(requirements.path).filter((n) => /\.(txt|in)$/i.test(n)).map((n) => `requirements/${n}`));
268
- } catch { /* no requirements dir */ }
269
- for (const n of names) {
270
- const t = readRepoText(boundary, n);
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
- present = true;
273
- const dev = /dev|test|lint|doc|ci/i.test(n.replace(/^requirements[/\\]?/i, ""));
274
- for (const d of parseRequirementsNames(t)) deps.push({ ...d, dev });
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 pp = readRepoText(boundary, "pyproject.toml");
277
- if (pp != null) { const r = parsePyprojectDeps(pp); if (r.present) { present = true; deps.push(...r.deps); } }
278
- const pf = readRepoText(boundary, "Pipfile");
279
- if (pf != null) { const r = parsePipfileDeps(pf); if (r.present) { present = true; deps.push(...r.deps); } }
280
- return { present, deps };
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) @method)
8
- (struct_item name: (type_identifier) @class)
9
- (enum_item name: (type_identifier) @class)
10
- (trait_item name: (type_identifier) @class)
11
- (type_item name: (type_identifier) @class)
12
- (mod_item name: (identifier) @class)
13
- (const_item name: (identifier) @field)
14
- (static_item name: (identifier) @field)`;
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
- `(macro_definition name: (identifier) @method)`,
18
- `(union_item name: (type_identifier) @class)`,
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
- addSym(cap.node.text, cap.node.startPosition.row + 1, cap.name === "method", { sourceNode: cap.node.parent });
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