weavatrix 0.2.13 → 0.2.15

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.
Files changed (145) hide show
  1. package/README.md +99 -39
  2. package/SECURITY.md +2 -2
  3. package/docs/releases/v0.2.15.md +37 -0
  4. package/package.json +2 -2
  5. package/skill/SKILL.md +57 -38
  6. package/src/analysis/architecture/contract-graph.js +119 -0
  7. package/src/analysis/architecture/contract-schema.js +110 -0
  8. package/src/analysis/architecture/contract-storage.js +35 -0
  9. package/src/analysis/architecture/contract-verification.js +168 -0
  10. package/src/analysis/architecture-contract.js +16 -343
  11. package/src/analysis/change-classification/diff-parser.js +103 -0
  12. package/src/analysis/change-classification/options.js +40 -0
  13. package/src/analysis/change-classification/symbol-classifier.js +177 -0
  14. package/src/analysis/change-classification.js +120 -519
  15. package/src/analysis/dead-code-review/policy.js +23 -0
  16. package/src/analysis/dead-code-review.js +9 -19
  17. package/src/analysis/dep-check.js +14 -71
  18. package/src/analysis/dep-rules.js +10 -303
  19. package/src/analysis/dependency/conventions.js +16 -0
  20. package/src/analysis/dependency/scoped-dependencies.js +45 -0
  21. package/src/analysis/dependency/source-references.js +21 -0
  22. package/src/analysis/duplicates.tokenize.js +12 -2
  23. package/src/analysis/endpoints/common.js +62 -0
  24. package/src/analysis/endpoints/extract.js +107 -0
  25. package/src/analysis/endpoints/inventory.js +84 -0
  26. package/src/analysis/endpoints/mounts.js +129 -0
  27. package/src/analysis/endpoints-java.js +80 -3
  28. package/src/analysis/endpoints.js +3 -448
  29. package/src/analysis/git-history/analytics.js +177 -0
  30. package/src/analysis/git-history/collector.js +109 -0
  31. package/src/analysis/git-history/options.js +68 -0
  32. package/src/analysis/git-history.js +128 -577
  33. package/src/analysis/health-capabilities.js +82 -0
  34. package/src/analysis/http-contracts/analysis.js +169 -0
  35. package/src/analysis/http-contracts/client-call-detection.js +81 -0
  36. package/src/analysis/http-contracts/client-call-parser.js +255 -0
  37. package/src/analysis/http-contracts/graph-context.js +143 -0
  38. package/src/analysis/http-contracts/matching.js +61 -0
  39. package/src/analysis/http-contracts/shared.js +86 -0
  40. package/src/analysis/http-contracts.js +6 -822
  41. package/src/analysis/internal-audit/dependency-health.js +111 -0
  42. package/src/analysis/internal-audit/python-manifests.js +65 -0
  43. package/src/analysis/internal-audit/repo-files.js +55 -0
  44. package/src/analysis/internal-audit/supply-chain.js +132 -0
  45. package/src/analysis/internal-audit.collect.js +5 -106
  46. package/src/analysis/internal-audit.reach.js +20 -0
  47. package/src/analysis/internal-audit.run.js +114 -200
  48. package/src/analysis/jvm-dependency-evidence.js +69 -0
  49. package/src/analysis/source-correctness/retry-patterns.js +93 -0
  50. package/src/analysis/source-correctness.js +225 -0
  51. package/src/analysis/structure/dependency-graph.js +156 -0
  52. package/src/analysis/structure/findings.js +142 -0
  53. package/src/analysis/task-retrieval.js +3 -14
  54. package/src/graph/builder/go-receiver-resolution.js +112 -0
  55. package/src/graph/builder/js/queries.js +48 -0
  56. package/src/graph/builder/lang-go.js +89 -4
  57. package/src/graph/builder/lang-js.js +4 -44
  58. package/src/graph/builder/pass2-resolution.js +68 -0
  59. package/src/graph/freshness-probe.js +2 -1
  60. package/src/graph/incremental-refresh.js +3 -2
  61. package/src/graph/internal-builder.build.js +22 -183
  62. package/src/graph/internal-builder.pass2.js +161 -0
  63. package/src/graph/internal-builder.resolvers.js +2 -105
  64. package/src/graph/resolvers/rust.js +117 -0
  65. package/src/mcp/actions/advisories.mjs +18 -0
  66. package/src/mcp/actions/graph-lifecycle.mjs +148 -0
  67. package/src/mcp/actions/graph-sync.mjs +195 -0
  68. package/src/mcp/actions/hosted-architecture.mjs +57 -0
  69. package/src/mcp/architecture-bootstrap.mjs +168 -0
  70. package/src/mcp/architecture-starter.mjs +234 -0
  71. package/src/mcp/catalog.mjs +20 -6
  72. package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
  73. package/src/mcp/evidence/duplicate-member-order.mjs +8 -0
  74. package/src/mcp/evidence/package-graph-common.mjs +115 -0
  75. package/src/mcp/evidence/package-lock-graph.mjs +92 -0
  76. package/src/mcp/evidence-snapshot.duplicates.mjs +3 -7
  77. package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
  78. package/src/mcp/git-output.mjs +10 -0
  79. package/src/mcp/graph/context-core.mjs +111 -0
  80. package/src/mcp/graph/context-seeds.mjs +197 -0
  81. package/src/mcp/graph/context-state.mjs +86 -0
  82. package/src/mcp/graph/reverse-reach.mjs +42 -0
  83. package/src/mcp/graph/tools-core.mjs +143 -0
  84. package/src/mcp/graph/tools-query.mjs +223 -0
  85. package/src/mcp/graph-context.mjs +4 -496
  86. package/src/mcp/health/audit-format.mjs +172 -0
  87. package/src/mcp/health/audit.mjs +171 -0
  88. package/src/mcp/health/dead-code.mjs +110 -0
  89. package/src/mcp/health/duplicates.mjs +83 -0
  90. package/src/mcp/health/endpoints.mjs +42 -0
  91. package/src/mcp/health/structure.mjs +219 -0
  92. package/src/mcp/runtime-version.mjs +32 -0
  93. package/src/mcp/server/auto-refresh.mjs +66 -0
  94. package/src/mcp/server/runtime-config.mjs +43 -0
  95. package/src/mcp/server/shutdown.mjs +40 -0
  96. package/src/mcp/sync/evidence-architecture.mjs +54 -0
  97. package/src/mcp/sync/evidence-common.mjs +88 -0
  98. package/src/mcp/sync/evidence-duplicates.mjs +97 -0
  99. package/src/mcp/sync/evidence-health.mjs +56 -0
  100. package/src/mcp/sync/evidence-packages.mjs +95 -0
  101. package/src/mcp/sync/payload-common.mjs +97 -0
  102. package/src/mcp/sync/payload-v2.mjs +53 -0
  103. package/src/mcp/sync/payload-v3.mjs +79 -0
  104. package/src/mcp/sync-evidence.mjs +7 -402
  105. package/src/mcp/sync-payload.mjs +10 -244
  106. package/src/mcp/tools-actions.mjs +18 -414
  107. package/src/mcp/tools-architecture.mjs +18 -70
  108. package/src/mcp/tools-context.mjs +56 -15
  109. package/src/mcp/tools-endpoints.mjs +4 -1
  110. package/src/mcp/tools-graph.mjs +17 -331
  111. package/src/mcp/tools-health.mjs +24 -705
  112. package/src/mcp/tools-impact-change.mjs +2 -38
  113. package/src/mcp/tools-impact.mjs +2 -43
  114. package/src/mcp-server.mjs +35 -146
  115. package/src/path-classification.js +14 -0
  116. package/src/precision/lsp-client/constants.js +12 -0
  117. package/src/precision/lsp-client/environment.js +20 -0
  118. package/src/precision/lsp-client/errors.js +15 -0
  119. package/src/precision/lsp-client/lifecycle.js +127 -0
  120. package/src/precision/lsp-client/message-parser.js +81 -0
  121. package/src/precision/lsp-client/protocol.js +212 -0
  122. package/src/precision/lsp-client/registry.js +58 -0
  123. package/src/precision/lsp-client/repo-uri.js +60 -0
  124. package/src/precision/lsp-client/stdio-client.js +151 -0
  125. package/src/precision/lsp-client.js +10 -682
  126. package/src/precision/lsp-overlay/build.js +238 -0
  127. package/src/precision/lsp-overlay/contract.js +61 -0
  128. package/src/precision/lsp-overlay/reference-results.js +108 -0
  129. package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
  130. package/src/precision/lsp-overlay/source-session.js +134 -0
  131. package/src/precision/lsp-overlay/store.js +150 -0
  132. package/src/precision/lsp-overlay/target-index.js +147 -0
  133. package/src/precision/lsp-overlay/target-query.js +55 -0
  134. package/src/precision/lsp-overlay.js +11 -868
  135. package/src/precision/typescript-lsp-provider.js +12 -682
  136. package/src/precision/typescript-provider/client.js +69 -0
  137. package/src/precision/typescript-provider/discovery.js +124 -0
  138. package/src/precision/typescript-provider/project-host.js +249 -0
  139. package/src/precision/typescript-provider/project-safety.js +161 -0
  140. package/src/precision/typescript-provider/reference-usage.js +53 -0
  141. package/src/security/malware-heuristics.sweep.js +1 -1
  142. package/src/security/registry-sig.classify.js +2 -1
  143. package/src/security/registry-sig.rules.js +3 -3
  144. package/src/version.js +7 -0
  145. package/docs/releases/v0.2.13.md +0 -48
@@ -1,448 +1,3 @@
1
- // endpoints.js detect the repo's OWN HTTP API surface (routes it EXPOSES), so the Health tab can
2
- // surface endpoints the way infra towers surface the services a repo TALKS TO. Each endpoint carries a
3
- // best-effort handler NAME so the UI can join it to that method's health (O(n) complexity, criticality,
4
- // coverage). Covers the common shapes across JS/TS, Python, Go and Rust:
5
- // • object routes (Bun.serve / custom): "/path": { GET: handler, POST: fn } | "/path": handler
6
- // • method-call routes (Express/Fastify/Koa/Hono/gin/echo): app.get("/path", handler)
7
- // • decorators (FastAPI/Flask/NestJS): @app.get("/path") / @Get("/path")
8
- // • Go net/http: mux.HandleFunc("/path", handler)
9
- // • Rust axum/actix-web: .route("/path", get(handler)) / #[get("/path")]
10
- import { safeRead } from "../util.js";
11
- import { createRepoBoundary } from "../repo-path.js";
12
- import { extractRustEndpoints } from "./endpoints-rust.js";
13
- import { extractSpringEndpoints } from "./endpoints-java.js";
14
- import { posix } from "node:path";
15
-
16
- const MAX_FILES = 3000;
17
- const MAX_ENDPOINTS = 2000;
18
- const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT", "ALL", "ANY"]);
19
- // UNAMBIGUOUS HTTP CLIENTS (make requests) vs servers (define routes) — reject `axios.get("/x")`-style client
20
- // calls in frontend code. Ambiguous names (api/client/service — could be a server router) are NOT listed;
21
- // they're filtered by the handler requirement instead (a client call has no handler / a config object arg).
22
- const HTTP_CLIENT_CALLER = /^(axios|https?|fetch|ky|got|superagent|needle|undici|xhr|\$http|http[Cc]lient|api[Cc]lient|rest[Cc]lient)$/;
23
-
24
-
25
- // 1-based line of a string index
26
- function lineAt(text, index) {
27
- let line = 1;
28
- for (let i = 0; i < index && i < text.length; i++) if (text[i] === "\n") line++;
29
- return line;
30
- }
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
-
64
- // best-effort bare handler name from a value expression: the LAST identifier, unwrapping wrappers like
65
- // executionRoute(queryHandlers.executeQuery) → executeQuery, asyncHandler(fn) → fn, a.b.c → c.
66
- // An INLINE handler (arrow / function literal) has no named method to join to, so it returns "" (the
67
- // UI then shows "inline") — grabbing an identifier out of an arrow body only invents garbage names.
68
- function handlerName(expr) {
69
- const s = String(expr || "").trim();
70
- if (!s) return "";
71
- if (/=>/.test(s) || /^\s*(async\s+)?function\b/.test(s) || /^\s*(?:async\s+)?(?:move\s+)?\|[^|]*\|/.test(s)) return "";
72
- const turbofish = /(?:^|::)([A-Za-z_][\w]*)\s*::<[\s\S]*>\s*$/.exec(s);
73
- if (turbofish) return turbofish[1];
74
- const ids = s.match(/[A-Za-z_$][\w$]*/g);
75
- if (!ids) return "";
76
- const SKIP = new Set(["async", "function", "await", "req", "res", "ctx", "request", "response", "next", "return"]);
77
- for (let i = ids.length - 1; i >= 0; i--) if (!SKIP.has(ids[i])) return ids[i];
78
- return "";
79
- }
80
-
81
- // An OpenAPI / Swagger spec object (e.g. `*.openapi.js` `paths`) documents routes rather than serving
82
- // them — its "handlers" are operation()/spec objects, so treating it as a route table produced fake
83
- // endpoints (handler "operation", {id} params). These keys never appear in a real route/handler table.
84
- const OPENAPI_BLOCK = /\boperationId\b|\bresponses\s*:|\brequestBody\b|\bschemaRef\b|\boperation\s*\(|\bsummary\s*:/;
85
-
86
- const looksLikePath = (p) => typeof p === "string" && /^\/[\w\-./:{}*$?]*$/.test(p) && !p.includes("://");
87
- const cleanPath = (p) => String(p || "").replace(/\/+$/, "") || "/";
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
-
235
- export function nextRoutePath(file) {
236
- const parts = String(file || "").replace(/\\/g, "/").split("/").filter(Boolean);
237
- if (!/^route\.[cm]?[jt]s$/i.test(parts.at(-1) || "")) return "";
238
- const appAt = parts.lastIndexOf("app");
239
- if (appAt < 0) return "";
240
- const route = [];
241
- for (let segment of parts.slice(appAt + 1, -1)) {
242
- if (!segment || /^\([^)]*\)$/.test(segment) || segment.startsWith("@")) continue; // route groups / parallel slots
243
- segment = segment.replace(/^\((?:\.{1,3})\)/, ""); // intercepting-route marker
244
- let m;
245
- if ((m = /^\[\[\.\.\.([^\]]+)\]\]$/.exec(segment))) segment = `*${m[1]}?`;
246
- else if ((m = /^\[\.\.\.([^\]]+)\]$/.exec(segment))) segment = `*${m[1]}`;
247
- else if ((m = /^\[([^\]]+)\]$/.exec(segment))) segment = `:${m[1]}`;
248
- if (segment) route.push(segment);
249
- }
250
- return `/${route.join("/")}`;
251
- }
252
-
253
- export function extractEndpointsFromText(text, file) {
254
- const out = [];
255
- const py = /\.py$/i.test(file);
256
- const rust = /\.rs$/i.test(file);
257
- const java = /\.java$/i.test(file);
258
- const scanText = maskComments(text, { hashComments: py });
259
- const add = (method, path, expr, idx) => {
260
- const p = cleanPath(path);
261
- if (!looksLikePath(p)) return;
262
- const m = String(method || "ANY").toUpperCase();
263
- if (!HTTP_METHODS.has(m)) return;
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) });
267
- };
268
-
269
- // Next.js App Router: the filesystem provides the path and exported HTTP-method functions provide the
270
- // verbs. No literal route string exists in route.ts, so generic Express/FastAPI regexes cannot see it.
271
- const nextPath = nextRoutePath(file);
272
- if (nextPath) {
273
- const seen = new Set();
274
- const direct = /\bexport\s+(?:(?:async|declare)\s+)*(?:function\s+|(?:const|let|var)\s+)(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/g;
275
- let nm;
276
- while ((nm = direct.exec(scanText))) {
277
- const method = nm[1].toUpperCase();
278
- if (!seen.has(method)) { seen.add(method); add(method, nextPath, method, nm.index); }
279
- }
280
- const lists = /\bexport\s*\{([^}]+)\}/g;
281
- let lm;
282
- while ((lm = lists.exec(scanText))) {
283
- for (const item of lm[1].split(",")) {
284
- const mm = /^\s*([A-Za-z_$][\w$]*)(?:\s+as\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS))?\s*$/.exec(item);
285
- if (!mm) continue;
286
- if (!mm[2] && !HTTP_METHODS.has(mm[1])) continue;
287
- const method = String(mm[2] || mm[1]).toUpperCase();
288
- if (!HTTP_METHODS.has(method)) continue;
289
- if (!seen.has(method)) { seen.add(method); add(method, nextPath, mm[1], lm.index); }
290
- }
291
- }
292
- }
293
-
294
- if (rust) extractRustEndpoints(scanText, add);
295
- if (java) {
296
- out.push(...extractSpringEndpoints(scanText, file));
297
- return out; // generic JS-style method calls would turn Java HTTP clients into fake server routes
298
- }
299
-
300
- // ---- object routes: "/path": { GET: fn, POST: fn2 } or "/path": handler --------------------
301
- // find each "…": { or "…": expr, where the key looks like a path
302
- const objKeyRe = /(["'`])(\/[^"'`]*)\1\s*:\s*(\{)?/g;
303
- let m;
304
- while ((m = objKeyRe.exec(scanText))) {
305
- const path = m[2], keyIdx = m.index;
306
- if (m[3]) {
307
- // object of METHOD: handler — scan to the matching close brace (routes objects are shallow)
308
- let i = objKeyRe.lastIndex, depth = 1;
309
- const start = i;
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);
312
- objKeyRe.lastIndex = i;
313
- if (OPENAPI_BLOCK.test(body)) continue; // documentation, not a route table
314
- const methodRe = /\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b\s*:\s*([^,\n}]+)/gi;
315
- let mm;
316
- while ((mm = methodRe.exec(body))) add(mm[1], path, mm[2], keyIdx);
317
- } else {
318
- // "/path": handlerExpr — a direct handler (any method); grab up to the next , or }
319
- const tail = scanText.slice(objKeyRe.lastIndex, objKeyRe.lastIndex + 200);
320
- const em = /^([^,\n}]+)/.exec(tail);
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);
323
- }
324
- }
325
-
326
- // ---- method-call routes: app.get("/path", handler) / router.post(...) / r.GET(...) ------------
327
- // (?<!@) so a DECORATOR like @router.get("/x") isn't also caught here (handler-less); decorators are
328
- // handled separately below where the handler is the following def. The CALLER is captured so we can reject
329
- // CLIENT HTTP calls (axios.get("/api/users"), http.get(url), apiClient.post(...)) — those are requests in
330
- // FRONTEND code, not server routes. A server route also REQUIRES a handler arg (an identifier/function),
331
- // so a bare `client.get("/x")` or one whose 2nd arg is a config object literal `{…}` is skipped.
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;
333
- while ((m = callRe.exec(scanText))) {
334
- const caller = m[1], arg2 = String(m[5] || "").trim();
335
- if (HTTP_CLIENT_CALLER.test(caller)) continue; // axios/http/fetch/apiClient… → a client request
336
- if (!arg2 || arg2[0] === "{") continue; // no handler, or a config object → not a route def
337
- add(m[2], m[4], m[5] || "", m.index);
338
- }
339
-
340
- // ---- Go net/http: mux.HandleFunc("/path", handler) / http.Handle("/path", h) ------------------
341
- const goRe = /\.\s*(?:HandleFunc|Handle)\s*\(\s*(["'`])(\/[^"'`]*)\1\s*,\s*([\s\S]{0,120}?)\)/g;
342
- while ((m = goRe.exec(scanText))) add("ANY", m[2], m[3], m.index);
343
-
344
- // ---- decorators: @app.get("/path") / @router.post("/path") / @Get("/path") -------------------
345
- if (py || /\.(ts|js|tsx|jsx|cjs|mjs)$/i.test(file)) {
346
- const decoRe = /@[\w$]*\.?\s*(get|post|put|patch|delete|head|options)\s*\(\s*(["'`])(\/[^"'`]*)\2/gi;
347
- while ((m = decoRe.exec(scanText))) {
348
- // the handler is the def/function on a following line — best-effort: next def name
349
- const after = scanText.slice(decoRe.lastIndex, decoRe.lastIndex + 200);
350
- const fn = /\b(?:def|async\s+def|function|const|export\s+function)\s+([A-Za-z_$][\w$]*)/.exec(after);
351
- add(m[1], m[3], fn ? fn[1] : "", m.index);
352
- }
353
- }
354
-
355
- return out;
356
- }
357
-
358
- // `{id}` (OpenAPI) and `:id` (JS routers) are the SAME route — normalize for dedup so a real handler
359
- // route isn't listed twice alongside its doc counterpart.
360
- const normParamKey = (p) => String(p).replace(/\{([^/}]+)\}/g, ":$1");
361
-
362
- // Detect endpoints across the repo's code files (from the graph's file nodes, or a caller-supplied list
363
- // of {path, full}). Deduped by method+normalized-path; on a collision the entry with a resolvable
364
- // handler (and `:param` display) wins. Capped. Returns [{method, path, handler, file, line}].
365
- export function analyzeEndpointInventory(repoPath, codeFiles) {
366
- const files = (codeFiles || []).slice(0, MAX_FILES);
367
- const byKey = new Map();
368
- const declarations = new Map();
369
- const boundary = createRepoBoundary(repoPath);
370
- const sources = new Map();
371
- const eligibleFiles = [];
372
- for (const f of files) {
373
- const rel = normalizedFile(f.path || f);
374
- if (!/\.(js|ts|tsx|jsx|cjs|mjs|py|go|rs|java)$/i.test(rel)) continue;
375
- const resolved = boundary.resolve(rel);
376
- if (!resolved.ok) continue;
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);
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;
386
- for (const e of extractEndpointsFromText(text, rel.replace(/\\/g, "/"))) {
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;
408
- }
409
- if (truncated) break;
410
- }
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;
435
- }
436
-
437
- // true when candidate `a` is a better representative of a route than the already-kept `b`:
438
- // a resolvable handler beats none; failing that, a `:param` path beats a `{param}` one.
439
- function preferEndpoint(a, b) {
440
- const ha = a.handler ? 1 : 0, hb = b.handler ? 1 : 0;
441
- if (ha !== hb) return ha > hb;
442
- const ca = a.path.includes("{") ? 1 : 0, cb = b.path.includes("{") ? 1 : 0;
443
- return ca < cb;
444
- }
445
-
446
- function sortEndpoints(a, b) {
447
- return a.path.localeCompare(b.path) || a.method.localeCompare(b.method);
448
- }
1
+ // Stable public facade for server endpoint discovery.
2
+ export { nextRoutePath, extractEndpointsFromText } from "./endpoints/extract.js";
3
+ export { analyzeEndpointInventory, detectEndpoints } from "./endpoints/inventory.js";
@@ -0,0 +1,177 @@
1
+ import {isTestPath} from '../../graph/graph-filter.js'
2
+ import {isStructuralRelation} from '../../graph/relations.js'
3
+ import {
4
+ boundedHistoryInteger,
5
+ GIT_HISTORY_DEFAULTS as DEFAULTS,
6
+ GIT_HISTORY_HARD_CAPS as HARD_CAPS,
7
+ GIT_HISTORY_V,
8
+ graphEndpoint,
9
+ roundHistoryNumber as round,
10
+ safeHistoryPath,
11
+ } from './options.js'
12
+
13
+ function graphFilesAndAdjacency(graph = {}) {
14
+ const byId = new Map(), files = new Set()
15
+ for (const node of graph.nodes || []) {
16
+ const file = safeHistoryPath(node?.source_file)
17
+ if (!file) continue
18
+ byId.set(String(node.id), file)
19
+ files.add(file)
20
+ }
21
+ const adjacency = new Map([...files].map((file) => [file, new Set()]))
22
+ for (const link of graph.links || []) {
23
+ if (isStructuralRelation(link?.relation) || link?.barrelProxy === true) continue
24
+ const left = byId.get(String(graphEndpoint(link?.source)))
25
+ const right = byId.get(String(graphEndpoint(link?.target)))
26
+ if (!left || !right || left === right) continue
27
+ adjacency.get(left)?.add(right)
28
+ adjacency.get(right)?.add(left)
29
+ }
30
+ return {files, adjacency}
31
+ }
32
+
33
+ function graphDistanceAtMostTwo(left, right, adjacency) {
34
+ if (left === right) return 0
35
+ const neighbors = adjacency.get(left)
36
+ if (!neighbors) return null
37
+ if (neighbors.has(right)) return 1
38
+ for (const middle of neighbors) if (adjacency.get(middle)?.has(right)) return 2
39
+ return null
40
+ }
41
+
42
+ function percentile(value, positiveValues) {
43
+ if (!(value > 0) || !positiveValues.length) return 0
44
+ let atOrBelow = 0
45
+ for (const candidate of positiveValues) if (candidate <= value) atOrBelow++
46
+ return round(atOrBelow / positiveValues.length)
47
+ }
48
+
49
+ const pairSort = (left, right) => right.count - left.count
50
+ || right.confidence - left.confidence
51
+ || right.lift - left.lift
52
+ || left.left.localeCompare(right.left)
53
+ || left.right.localeCompare(right.right)
54
+
55
+ const publicPair = (pair, graphDistance) => ({
56
+ left: pair.left,
57
+ right: pair.right,
58
+ count: pair.count,
59
+ jaccard: pair.jaccard,
60
+ lift: pair.lift,
61
+ confidence: pair.confidence,
62
+ leftConfidence: pair.leftConfidence,
63
+ rightConfidence: pair.rightConfidence,
64
+ graphDistance,
65
+ })
66
+
67
+ function fileActivity(commits, graph) {
68
+ const activity = new Map(), fileCommits = new Map()
69
+ let additions = 0, deletions = 0, binaryChanges = 0
70
+ for (const commit of commits) {
71
+ const seen = new Set()
72
+ for (const stat of commit.files) {
73
+ if (seen.has(stat.file)) continue
74
+ seen.add(stat.file)
75
+ const entry = activity.get(stat.file) || {file: stat.file, commits: 0, additions: 0, deletions: 0, binaryChanges: 0}
76
+ entry.commits++
77
+ entry.additions += stat.additions
78
+ entry.deletions += stat.deletions
79
+ entry.binaryChanges += stat.binary ? 1 : 0
80
+ activity.set(stat.file, entry)
81
+ fileCommits.set(stat.file, (fileCommits.get(stat.file) || 0) + 1)
82
+ additions += stat.additions
83
+ deletions += stat.deletions
84
+ if (stat.binary) binaryChanges++
85
+ }
86
+ }
87
+ const {files: graphFiles, adjacency} = graphFilesAndAdjacency(graph)
88
+ const raw = [...activity.values()].map((entry) => ({...entry, churn: entry.additions + entry.deletions}))
89
+ const churnValues = raw.map((entry) => entry.churn).filter((value) => value > 0)
90
+ const connectivityValues = [...graphFiles].map((file) => adjacency.get(file)?.size || 0).filter((value) => value > 0)
91
+ const fileChurn = raw.map((entry) => {
92
+ const connectivity = adjacency.get(entry.file)?.size || 0
93
+ const churnPercentile = percentile(entry.churn, churnValues)
94
+ const connectivityPercentile = percentile(connectivity, connectivityValues)
95
+ return {...entry, connectivity, churnPercentile, connectivityPercentile, hotspotScore: round(Math.sqrt(churnPercentile * connectivityPercentile))}
96
+ }).sort((a, b) => b.churn - a.churn || b.commits - a.commits || a.file.localeCompare(b.file))
97
+ const hotspots = fileChurn.filter((entry) => entry.connectivity > 0)
98
+ .sort((a, b) => b.hotspotScore - a.hotspotScore || b.churn - a.churn || b.connectivity - a.connectivity || a.file.localeCompare(b.file))
99
+ return {fileCommits, graphFiles, adjacency, fileChurn, hotspots, additions, deletions, binaryChanges}
100
+ }
101
+
102
+ function coChangePairs(commits, fileCommits, limits) {
103
+ const counts = new Map()
104
+ let truncated = false
105
+ for (const commit of commits) {
106
+ const files = [...new Set(commit.files.map((entry) => entry.file))].sort()
107
+ for (let left = 0; left < files.length; left++) for (let right = left + 1; right < files.length; right++) {
108
+ const key = `${files[left]}\0${files[right]}`
109
+ if (!counts.has(key) && counts.size >= limits.maxPairCandidates) { truncated = true; continue }
110
+ counts.set(key, (counts.get(key) || 0) + 1)
111
+ }
112
+ }
113
+ const pairs = []
114
+ for (const [key, count] of counts) {
115
+ if (count < limits.minPairCount || !commits.length) continue
116
+ const split = key.indexOf('\0'), left = key.slice(0, split), right = key.slice(split + 1)
117
+ const leftCount = fileCommits.get(left) || 0, rightCount = fileCommits.get(right) || 0
118
+ const leftConfidence = count / leftCount, rightConfidence = count / rightCount
119
+ pairs.push({
120
+ left, right, count,
121
+ jaccard: round(count / (leftCount + rightCount - count)),
122
+ lift: round((count * commits.length) / (leftCount * rightCount)),
123
+ confidence: round(Math.max(leftConfidence, rightConfidence)),
124
+ leftConfidence: round(leftConfidence),
125
+ rightConfidence: round(rightConfidence),
126
+ })
127
+ }
128
+ return {pairs: pairs.sort(pairSort), totalCandidates: counts.size, truncated}
129
+ }
130
+
131
+ export function buildGitHistoryAnalytics({commits = [], graph = {}, window, limits = {}, status = 'complete'} = {}) {
132
+ const maxPairs = boundedHistoryInteger(limits.maxPairs, DEFAULTS.maxPairs, 1, HARD_CAPS.maxPairs)
133
+ const minPairCount = boundedHistoryInteger(limits.minPairCount, DEFAULTS.minPairCount, 1, 100)
134
+ const maxPairCandidates = boundedHistoryInteger(limits.maxPairCandidates, DEFAULTS.maxPairCandidates, 100, HARD_CAPS.maxPairCandidates)
135
+ const eligible = commits.filter((commit) => !commit.oversized && commit.files.length > 0)
136
+ const skipped = commits.filter((commit) => commit.oversized)
137
+ const activity = fileActivity(eligible, graph)
138
+ const cochange = coChangePairs(eligible, activity.fileCommits, {minPairCount, maxPairCandidates})
139
+ const observed = cochange.pairs.slice(0, maxPairs).map((pair) => publicPair(pair, graphDistanceAtMostTwo(pair.left, pair.right, activity.adjacency)))
140
+ const expectedTestSource = cochange.pairs.filter((pair) => isTestPath(pair.left) !== isTestPath(pair.right)).slice(0, maxPairs).map((pair) => {
141
+ const test = isTestPath(pair.left) ? pair.left : pair.right
142
+ const source = test === pair.left ? pair.right : pair.left
143
+ return {
144
+ source, test, count: pair.count, jaccard: pair.jaccard, lift: pair.lift, confidence: pair.confidence,
145
+ sourceConfidence: source === pair.left ? pair.leftConfidence : pair.rightConfidence,
146
+ testConfidence: test === pair.left ? pair.leftConfidence : pair.rightConfidence,
147
+ graphDistance: graphDistanceAtMostTwo(source, test, activity.adjacency),
148
+ }
149
+ })
150
+ const hidden = cochange.pairs.filter((pair) => !isTestPath(pair.left) && !isTestPath(pair.right)
151
+ && activity.graphFiles.has(pair.left) && activity.graphFiles.has(pair.right)
152
+ && graphDistanceAtMostTwo(pair.left, pair.right, activity.adjacency) === null)
153
+ .slice(0, maxPairs).map((pair) => publicPair(pair, null))
154
+ const reasons = [
155
+ skipped.length ? `${skipped.length} oversized change-set(s) excluded` : null,
156
+ cochange.truncated ? 'co-change candidate cap reached' : null,
157
+ status === 'partial' ? 'git output or commit window was truncated' : null,
158
+ ].filter(Boolean)
159
+ return {
160
+ gitHistoryV: GIT_HISTORY_V,
161
+ status: reasons.length ? 'partial' : status,
162
+ window: window || null,
163
+ limits: {maxCommits: limits.maxCommits ?? null, maxFilesPerCommit: limits.maxFilesPerCommit ?? null, maxPairs, minPairCount, maxPairCandidates},
164
+ completeness: {complete: reasons.length === 0, reasons},
165
+ totals: {
166
+ commitsRead: commits.length, commitsAnalyzed: eligible.length, oversizedCommitsSkipped: skipped.length,
167
+ files: activity.fileChurn.length, additions: activity.additions, deletions: activity.deletions,
168
+ churn: activity.additions + activity.deletions, binaryChanges: activity.binaryChanges,
169
+ ignoredFiles: commits.reduce((sum, commit) => sum + (commit.ignoredFiles || 0), 0),
170
+ invalidPaths: commits.reduce((sum, commit) => sum + (commit.invalidPaths || 0), 0),
171
+ graphFiles: activity.graphFiles.size,
172
+ },
173
+ fileChurn: activity.fileChurn,
174
+ hotspots: activity.hotspots,
175
+ coupling: {eligibleCommits: eligible.length, totalCandidates: cochange.totalCandidates, candidatesTruncated: cochange.truncated, observed, expectedTestSource, hidden},
176
+ }
177
+ }