weavatrix 0.2.13 → 0.2.14
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 +83 -39
- package/SECURITY.md +2 -2
- package/docs/releases/v0.2.14.md +93 -0
- package/package.json +2 -2
- package/skill/SKILL.md +57 -38
- package/src/analysis/architecture/contract-graph.js +119 -0
- package/src/analysis/architecture/contract-schema.js +110 -0
- package/src/analysis/architecture/contract-storage.js +35 -0
- package/src/analysis/architecture/contract-verification.js +168 -0
- package/src/analysis/architecture-contract.js +16 -343
- package/src/analysis/change-classification/diff-parser.js +103 -0
- package/src/analysis/change-classification/options.js +40 -0
- package/src/analysis/change-classification/symbol-classifier.js +177 -0
- package/src/analysis/change-classification.js +120 -519
- package/src/analysis/dead-code-review/policy.js +23 -0
- package/src/analysis/dead-code-review.js +9 -19
- package/src/analysis/dep-check.js +10 -57
- package/src/analysis/dep-rules.js +10 -303
- package/src/analysis/dependency/scoped-dependencies.js +40 -0
- package/src/analysis/endpoints/common.js +62 -0
- package/src/analysis/endpoints/extract.js +107 -0
- package/src/analysis/endpoints/inventory.js +84 -0
- package/src/analysis/endpoints/mounts.js +129 -0
- package/src/analysis/endpoints-java.js +80 -3
- package/src/analysis/endpoints.js +3 -448
- package/src/analysis/git-history/analytics.js +177 -0
- package/src/analysis/git-history/collector.js +109 -0
- package/src/analysis/git-history/options.js +68 -0
- package/src/analysis/git-history.js +128 -577
- package/src/analysis/health-capabilities.js +82 -0
- package/src/analysis/http-contracts/analysis.js +169 -0
- package/src/analysis/http-contracts/client-call-detection.js +81 -0
- package/src/analysis/http-contracts/client-call-parser.js +255 -0
- package/src/analysis/http-contracts/graph-context.js +143 -0
- package/src/analysis/http-contracts/matching.js +61 -0
- package/src/analysis/http-contracts/shared.js +86 -0
- package/src/analysis/http-contracts.js +6 -822
- package/src/analysis/internal-audit/dependency-health.js +111 -0
- package/src/analysis/internal-audit/python-manifests.js +65 -0
- package/src/analysis/internal-audit/repo-files.js +55 -0
- package/src/analysis/internal-audit/supply-chain.js +132 -0
- package/src/analysis/internal-audit.collect.js +5 -106
- package/src/analysis/internal-audit.run.js +113 -200
- package/src/analysis/jvm-dependency-evidence.js +69 -0
- package/src/analysis/source-correctness/retry-patterns.js +93 -0
- package/src/analysis/source-correctness.js +225 -0
- package/src/analysis/structure/dependency-graph.js +156 -0
- package/src/analysis/structure/findings.js +142 -0
- package/src/graph/builder/go-receiver-resolution.js +112 -0
- package/src/graph/builder/js/queries.js +48 -0
- package/src/graph/builder/lang-go.js +89 -4
- package/src/graph/builder/lang-js.js +4 -44
- package/src/graph/builder/pass2-resolution.js +68 -0
- package/src/graph/freshness-probe.js +2 -1
- package/src/graph/incremental-refresh.js +3 -2
- package/src/graph/internal-builder.build.js +22 -183
- package/src/graph/internal-builder.pass2.js +161 -0
- package/src/graph/internal-builder.resolvers.js +2 -105
- package/src/graph/resolvers/rust.js +117 -0
- package/src/mcp/actions/advisories.mjs +18 -0
- package/src/mcp/actions/graph-lifecycle.mjs +148 -0
- package/src/mcp/actions/graph-sync.mjs +195 -0
- package/src/mcp/actions/hosted-architecture.mjs +57 -0
- package/src/mcp/architecture-bootstrap.mjs +168 -0
- package/src/mcp/architecture-starter.mjs +234 -0
- package/src/mcp/catalog.mjs +20 -6
- package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
- package/src/mcp/evidence/package-graph-common.mjs +115 -0
- package/src/mcp/evidence/package-lock-graph.mjs +92 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
- package/src/mcp/graph/context-core.mjs +111 -0
- package/src/mcp/graph/context-seeds.mjs +208 -0
- package/src/mcp/graph/context-state.mjs +86 -0
- package/src/mcp/graph/tools-core.mjs +143 -0
- package/src/mcp/graph/tools-query.mjs +223 -0
- package/src/mcp/graph-context.mjs +4 -496
- package/src/mcp/health/audit-format.mjs +172 -0
- package/src/mcp/health/audit.mjs +171 -0
- package/src/mcp/health/dead-code.mjs +110 -0
- package/src/mcp/health/duplicates.mjs +83 -0
- package/src/mcp/health/endpoints.mjs +42 -0
- package/src/mcp/health/structure.mjs +219 -0
- package/src/mcp/runtime-version.mjs +32 -0
- package/src/mcp/server/auto-refresh.mjs +66 -0
- package/src/mcp/server/runtime-config.mjs +43 -0
- package/src/mcp/server/shutdown.mjs +40 -0
- package/src/mcp/sync/evidence-architecture.mjs +54 -0
- package/src/mcp/sync/evidence-common.mjs +88 -0
- package/src/mcp/sync/evidence-duplicates.mjs +100 -0
- package/src/mcp/sync/evidence-health.mjs +56 -0
- package/src/mcp/sync/evidence-packages.mjs +95 -0
- package/src/mcp/sync/payload-common.mjs +97 -0
- package/src/mcp/sync/payload-v2.mjs +53 -0
- package/src/mcp/sync/payload-v3.mjs +79 -0
- package/src/mcp/sync-evidence.mjs +7 -402
- package/src/mcp/sync-payload.mjs +10 -244
- package/src/mcp/tools-actions.mjs +18 -414
- package/src/mcp/tools-architecture.mjs +18 -70
- package/src/mcp/tools-context.mjs +56 -15
- package/src/mcp/tools-endpoints.mjs +4 -1
- package/src/mcp/tools-graph.mjs +17 -331
- package/src/mcp/tools-health.mjs +24 -705
- package/src/mcp-server.mjs +35 -146
- package/src/precision/lsp-client/constants.js +12 -0
- package/src/precision/lsp-client/environment.js +20 -0
- package/src/precision/lsp-client/errors.js +15 -0
- package/src/precision/lsp-client/lifecycle.js +127 -0
- package/src/precision/lsp-client/message-parser.js +81 -0
- package/src/precision/lsp-client/protocol.js +212 -0
- package/src/precision/lsp-client/registry.js +58 -0
- package/src/precision/lsp-client/repo-uri.js +60 -0
- package/src/precision/lsp-client/stdio-client.js +151 -0
- package/src/precision/lsp-client.js +10 -682
- package/src/precision/lsp-overlay/build.js +238 -0
- package/src/precision/lsp-overlay/contract.js +61 -0
- package/src/precision/lsp-overlay/reference-results.js +108 -0
- package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
- package/src/precision/lsp-overlay/source-session.js +134 -0
- package/src/precision/lsp-overlay/store.js +150 -0
- package/src/precision/lsp-overlay/target-index.js +147 -0
- package/src/precision/lsp-overlay/target-query.js +55 -0
- package/src/precision/lsp-overlay.js +11 -868
- package/src/precision/typescript-lsp-provider.js +12 -682
- package/src/precision/typescript-provider/client.js +69 -0
- package/src/precision/typescript-provider/discovery.js +124 -0
- package/src/precision/typescript-provider/project-host.js +249 -0
- package/src/precision/typescript-provider/project-safety.js +161 -0
- package/src/precision/typescript-provider/reference-usage.js +53 -0
- package/src/version.js +7 -0
- package/docs/releases/v0.2.13.md +0 -48
|
@@ -1,448 +1,3 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
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
|
+
}
|